From f57edc4d390929966de98faa96ff0fc40ebde7fd Mon Sep 17 00:00:00 2001 From: Hanashi Date: Wed, 5 Aug 2026 01:24:57 -0400 Subject: [PATCH] download settings --- .../get_app_preferences_command.rs | 9 +- .../update_app_preference_command.rs | 58 +++- .../handle_storyteller_successful_job.rs | 34 +- .../state/app_preferences/app_preferences.rs | 7 +- .../app_preferences_serializable.rs | 16 +- .../src/state/app_preferences/mod.rs | 1 - crates/artcraftx/src/state/downloads/mod.rs | 2 + .../preferred_download_directory.rs | 4 +- .../downloads/preferred_download_filename.rs | 318 ++++++++++++++++++ crates/artcraftx/src/state/mod.rs | 1 + .../download_url_to_download_dir_via_temp.rs | 19 +- .../download_url_to_user_download_dir.rs | 6 +- .../components/PromptShell/PromptShell.tsx | 21 +- .../PromptShell/useComposerTasks.ts | 8 +- .../src/lib/panes/DownloadsSettingsPane.tsx | 232 +++++++++++++ .../src/lib/panes/MiscSettingsPane.tsx | 121 +------ .../settings-modal/src/lib/settings-modal.tsx | 5 + .../src/lib/settings/GetAppPreferences.ts | 12 + .../src/lib/settings/UpdateAppPreference.ts | 5 +- 19 files changed, 731 insertions(+), 148 deletions(-) create mode 100644 crates/artcraftx/src/state/downloads/mod.rs rename crates/artcraftx/src/state/{app_preferences => downloads}/preferred_download_directory.rs (94%) create mode 100644 crates/artcraftx/src/state/downloads/preferred_download_filename.rs create mode 100644 frontend/libs/components/settings-modal/src/lib/panes/DownloadsSettingsPane.tsx diff --git a/crates/artcraftx/src/commands/service/app_preferences/get_app_preferences_command.rs b/crates/artcraftx/src/commands/service/app_preferences/get_app_preferences_command.rs index 767dae4e..5b797f3e 100644 --- a/crates/artcraftx/src/commands/service/app_preferences/get_app_preferences_command.rs +++ b/crates/artcraftx/src/commands/service/app_preferences/get_app_preferences_command.rs @@ -1,5 +1,6 @@ use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; -use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; +use crate::state::downloads::preferred_download_directory::PreferredDownloadDirectory; +use crate::state::downloads::preferred_download_filename::PreferredDownloadFilename; use errors::AnyhowResult; use log::{error, info}; use serde_derive::Serialize; @@ -14,7 +15,10 @@ pub struct GetAppPreferencesResponse { pub struct AppPreferencesPayload { /// The downloads directory to use when a user downloads a file. pub preferred_download_directory: PreferredDownloadDirectory, - + + /// How downloaded generation files are named on disk. + pub preferred_download_filename: PreferredDownloadFilename, + /// Play sounds on events. pub play_sounds: bool, @@ -63,6 +67,7 @@ async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult, } +/// NB: Untagged — variant ORDER matters. `DownloadFilename` must precede +/// `String` (its unit variant arrives as the string "artcraft_convention"), +/// and its custom variant keys on "custom_format" so it can't collide with +/// `DownloadDirectory`'s "custom". #[derive(Deserialize, Debug)] #[serde(untagged)] pub enum ValueType { Bool(bool), - String(String), DownloadDirectory(PreferredDownloadDirectory), + DownloadFilename(PreferredDownloadFilename), + String(String), } #[derive(Deserialize)] #[serde(rename_all = "snake_case")] pub enum PreferenceName { PreferredDownloadDirectory, + PreferredDownloadFilename, PlaySounds, DeleteFileSound, EnqueueSuccessSound, @@ -78,6 +85,19 @@ async fn update_prefs( return Err(anyhow!("Invalid value: {:?}", request.value)), } } + PreferenceName::PreferredDownloadFilename => { + match request.value { + Some(ValueType::DownloadFilename(filename)) => { + if let PreferredDownloadFilename::Custom(format) = &filename { + PreferredDownloadFilename::validate_custom_format(format) + .map_err(|reason| anyhow!("Invalid filename format: {}", reason))?; + } + prefs.preferred_download_filename = filename; + } + _ => + return Err(anyhow!("Invalid value: {:?}", request.value)), + } + } PreferenceName::PlaySounds => { match request.value { Some(ValueType::Bool(val)) => @@ -125,3 +145,37 @@ fn string_value(value: &ValueType) -> AnyhowResult { _ => Err(anyhow!("Invalid value type: {:?}", value)), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The untagged `ValueType` decode is order-sensitive; pin the behavior. + #[test] + fn value_type_decoding() { + assert!(matches!( + serde_json::from_str::("true").unwrap(), + ValueType::Bool(true), + )); + assert!(matches!( + serde_json::from_str::("\"artcraft_convention\"").unwrap(), + ValueType::DownloadFilename(PreferredDownloadFilename::ArtcraftConvention), + )); + assert!(matches!( + serde_json::from_str::("{\"custom_format\":\"{model}_{date}\"}").unwrap(), + ValueType::DownloadFilename(PreferredDownloadFilename::Custom(_)), + )); + assert!(matches!( + serde_json::from_str::("{\"custom\":\"/tmp\"}").unwrap(), + ValueType::DownloadDirectory(PreferredDownloadDirectory::Custom(_)), + )); + assert!(matches!( + serde_json::from_str::("{\"system\":\"downloads\"}").unwrap(), + ValueType::DownloadDirectory(PreferredDownloadDirectory::System(_)), + )); + assert!(matches!( + serde_json::from_str::("\"done\"").unwrap(), + ValueType::String(_), + )); + } +} diff --git a/crates/artcraftx/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs b/crates/artcraftx/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs index 95e539fb..ed2eea36 100644 --- a/crates/artcraftx/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs +++ b/crates/artcraftx/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs @@ -4,6 +4,8 @@ use crate::error::artcraftx_error::ArtcraftXError; use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::database::task_database::TaskDatabase; +use crate::state::downloads::preferred_download_filename::{model_slug_from_model_type_str, DownloadFilenameParts}; +use chrono::Local; use crate::utils::download_url_to_download_dir_via_temp::download_url_to_download_dir_via_temp; use crate::utils::enum_conversion::generation_source::to_generation_service_provider; use crate::utils::enum_conversion::task_type::to_generation_action; @@ -71,7 +73,7 @@ pub async fn handle_successful_job( return Ok(()); // If anything breaks with queries, don't spam events. } - download_all_files(app_data_root, app_preferences, job, &media_files).await; + download_all_files(app_data_root, app_preferences, job, task, &media_files).await; send_additional_success_events(app_handle, job, task, &media_files).await; @@ -92,12 +94,14 @@ pub async fn handle_successful_job( } /// Download every file the job produced into the user's configured download -/// directory (temp dir first, then moved into place). Fails open per file so -/// one bad download doesn't lose the rest. +/// directory (temp dir first, then moved into place), named per the user's +/// preferred filename convention. Fails open per file so one bad download +/// doesn't lose the rest. async fn download_all_files( app_data_root: &AppDataRoot, app_preferences: &AppPreferencesManager, job: &JobStatusPayload, + task: &Task, media_files: &[JobMediaFileInfo], ) { let app_prefs = match app_preferences.get_clone() { @@ -122,8 +126,28 @@ async fn download_all_files( .collect::>() }; - for cdn_url in &cdn_urls { - match download_url_to_download_dir_via_temp(cdn_url, app_data_root, &app_prefs).await { + let model_slug = task.model_type + .as_ref() + .map(|model_type| model_slug_from_model_type_str(model_type.to_str())) + .unwrap_or_else(|| "artcraft".to_string()); + + let download_time = Local::now(); + + for (index, cdn_url) in cdn_urls.iter().enumerate() { + let extension = cdn_url.path() + .rsplit('.') + .next() + .unwrap_or("bin") + .to_string(); + + let filename = app_prefs.preferred_download_filename.build_filename(&DownloadFilenameParts { + model_slug: &model_slug, + download_time, + maybe_batch_index: (cdn_urls.len() > 1).then(|| index + 1), + extension: &extension, + }); + + match download_url_to_download_dir_via_temp(cdn_url, Some(&filename), app_data_root, &app_prefs).await { Ok(path) => { info!("Downloaded job {} file to {:?}", job.job_token.as_str(), path); } diff --git a/crates/artcraftx/src/state/app_preferences/app_preferences.rs b/crates/artcraftx/src/state/app_preferences/app_preferences.rs index f052497e..a542dc63 100644 --- a/crates/artcraftx/src/state/app_preferences/app_preferences.rs +++ b/crates/artcraftx/src/state/app_preferences/app_preferences.rs @@ -1,5 +1,6 @@ use crate::state::app_preferences::app_preferences_serializable::AppPreferencesSerializable; -use crate::state::app_preferences::preferred_download_directory::{PreferredDownloadDirectory, SystemDownloadDirectory}; +use crate::state::downloads::preferred_download_directory::{PreferredDownloadDirectory, SystemDownloadDirectory}; +use crate::state::downloads::preferred_download_filename::PreferredDownloadFilename; use crate::state::data_dir::app_data_root::AppDataRoot; #[derive(Clone)] @@ -7,6 +8,9 @@ pub struct AppPreferences { /// The downloads directory to use when a user downloads a file. pub preferred_download_directory: PreferredDownloadDirectory, + /// How downloaded generation files are named on disk. + pub preferred_download_filename: PreferredDownloadFilename, + /// Play sounds on events. pub play_sounds: bool, @@ -36,6 +40,7 @@ impl Default for AppPreferences { fn default() -> Self { Self { preferred_download_directory: PreferredDownloadDirectory::System(SystemDownloadDirectory::Downloads), + preferred_download_filename: PreferredDownloadFilename::ArtcraftConvention, play_sounds: true, // NB: These are defined in the frontend. enqueue_success_sound: Some("done".to_string()), diff --git a/crates/artcraftx/src/state/app_preferences/app_preferences_serializable.rs b/crates/artcraftx/src/state/app_preferences/app_preferences_serializable.rs index a2328796..3d10600b 100644 --- a/crates/artcraftx/src/state/app_preferences/app_preferences_serializable.rs +++ b/crates/artcraftx/src/state/app_preferences/app_preferences_serializable.rs @@ -1,5 +1,6 @@ use crate::state::app_preferences::app_preferences::AppPreferences; -use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; +use crate::state::downloads::preferred_download_directory::PreferredDownloadDirectory; +use crate::state::downloads::preferred_download_filename::PreferredDownloadFilename; use crate::state::data_dir::app_data_root::AppDataRoot; use errors::AnyhowResult; use serde_derive::{Deserialize, Serialize}; @@ -7,7 +8,8 @@ use serde_derive::{Deserialize, Serialize}; /// Vector clock versioning string rather than semver. /// - Version 1 - initial version. /// - Version 2 - added "delete_file_sound", marked optionals "skip_serializing_if" -const CURRENT_VERSION: &str = "2"; +/// - Version 3 - added "preferred_download_filename" +const CURRENT_VERSION: &str = "3"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppPreferencesSerializable { @@ -17,6 +19,11 @@ pub struct AppPreferencesSerializable { /// The downloads directory to use when a user downloads a file. pub preferred_download_directory: Option, + /// How downloaded generation files are named on disk. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub preferred_download_filename: Option, + /// Play sounds on events. #[serde(skip_serializing_if = "Option::is_none")] pub play_sounds: Option, @@ -54,6 +61,7 @@ impl AppPreferencesSerializable { Self { version: CURRENT_VERSION.to_string(), preferred_download_directory: Some(preferences.preferred_download_directory.clone()), + preferred_download_filename: Some(preferences.preferred_download_filename.clone()), play_sounds: Some(preferences.play_sounds), delete_file_sound: preferences.delete_file_sound.clone(), generation_success_sound: preferences.generation_success_sound.clone(), @@ -69,6 +77,10 @@ impl AppPreferencesSerializable { preferences.preferred_download_directory = preferred_download_directory.clone(); } + if let Some(preferred_download_filename) = &self.preferred_download_filename { + preferences.preferred_download_filename = preferred_download_filename.clone(); + } + if let Some(play_sounds) = self.play_sounds { preferences.play_sounds = play_sounds; } diff --git a/crates/artcraftx/src/state/app_preferences/mod.rs b/crates/artcraftx/src/state/app_preferences/mod.rs index d2385da9..b4900cd5 100644 --- a/crates/artcraftx/src/state/app_preferences/mod.rs +++ b/crates/artcraftx/src/state/app_preferences/mod.rs @@ -1,4 +1,3 @@ pub mod app_preferences; pub mod app_preferences_manager; pub mod app_preferences_serializable; -pub mod preferred_download_directory; \ No newline at end of file diff --git a/crates/artcraftx/src/state/downloads/mod.rs b/crates/artcraftx/src/state/downloads/mod.rs new file mode 100644 index 00000000..9aa42fce --- /dev/null +++ b/crates/artcraftx/src/state/downloads/mod.rs @@ -0,0 +1,2 @@ +pub mod preferred_download_directory; +pub mod preferred_download_filename; diff --git a/crates/artcraftx/src/state/app_preferences/preferred_download_directory.rs b/crates/artcraftx/src/state/downloads/preferred_download_directory.rs similarity index 94% rename from crates/artcraftx/src/state/app_preferences/preferred_download_directory.rs rename to crates/artcraftx/src/state/downloads/preferred_download_directory.rs index 2b12dc03..3b35cad6 100644 --- a/crates/artcraftx/src/state/app_preferences/preferred_download_directory.rs +++ b/crates/artcraftx/src/state/downloads/preferred_download_directory.rs @@ -81,8 +81,8 @@ impl Display for SystemDownloadDirectory { #[cfg(test)] mod tests { - use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; - use crate::state::app_preferences::preferred_download_directory::SystemDownloadDirectory; + use crate::state::downloads::preferred_download_directory::PreferredDownloadDirectory; + use crate::state::downloads::preferred_download_directory::SystemDownloadDirectory; mod json { diff --git a/crates/artcraftx/src/state/downloads/preferred_download_filename.rs b/crates/artcraftx/src/state/downloads/preferred_download_filename.rs new file mode 100644 index 00000000..71cdf345 --- /dev/null +++ b/crates/artcraftx/src/state/downloads/preferred_download_filename.rs @@ -0,0 +1,318 @@ +use chrono::{DateTime, Datelike, Local, Timelike}; +use serde_derive::{Deserialize, Serialize}; + +/// Characters that may never appear in a download filename (or a custom +/// filename format): path separators, traversal, quoting, and shell-unsafe +/// characters. +const UNSAFE_CHARACTERS: &[char] = &[ + '/', '\\', '\'', '"', '`', '%', '<', '>', '|', ':', '*', '?', '\0', +]; + +/// How downloaded generation files are named on disk. +/// +/// Stored in the app preferences file. NEVER change existing serialized +/// values; only add new ones. +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PreferredDownloadFilename { + /// `{model}_{date}(_{batch_index}).{ext}` + /// - model slug, eg. "seedance_2.0", "flux_pro_1.1" + /// - date is "YYYY-MM-DD-HH-MM-SS" (download time) + /// - batch index is 1-n when the job produced more than one file + /// - ext matches the cloud file (jpg, png, mp4, ...) + /// + /// NB: Serializes as `"artcraft_convention"` in JSON. + ArtcraftConvention, + + /// A user-defined format string. Supported tokens: + /// - `{model}` — the model slug + /// - `{date}` — "YYYY-MM-DD-HH-MM-SS" (download time) + /// - `{YYYY}`, `{YY}`, `{MM}`, `{DD}`, `{HH}`, `{mm}`, `{SS}` — date parts + /// - `{batch_index}` — 1-n (appended automatically when the job produced + /// more than one file and the format doesn't include it) + /// + /// Slash, quote, and other unsafe characters are rejected. + /// + /// NB: Serializes as `{"custom_format": "..."}` in JSON. (The key is NOT + /// "custom" so it can't be confused with `PreferredDownloadDirectory`'s + /// custom variant in loosely-typed payloads.) + #[serde(rename = "custom_format")] + Custom(String), +} + +impl Default for PreferredDownloadFilename { + fn default() -> Self { + Self::ArtcraftConvention + } +} + +/// Everything needed to render a download filename. +pub struct DownloadFilenameParts<'a> { + /// Model slug, eg. "seedance_2.0". Use [`model_slug_from_model_type_str`]. + pub model_slug: &'a str, + + /// The download time. + pub download_time: DateTime, + + /// 1-based index when the job produced more than one file. + pub maybe_batch_index: Option, + + /// File extension without the leading dot, matching the cloud file. + pub extension: &'a str, +} + +impl PreferredDownloadFilename { + /// Render the filename (including extension) for one downloaded file. + /// The output is sanitized: unsafe characters can never appear. + pub fn build_filename(&self, parts: &DownloadFilenameParts<'_>) -> String { + let stem = match self { + Self::ArtcraftConvention => { + let mut stem = format!( + "{}_{}", + parts.model_slug, + full_date_string(&parts.download_time), + ); + if let Some(index) = parts.maybe_batch_index { + stem.push_str(&format!("_{index}")); + } + stem + } + Self::Custom(format) => { + let mut stem = expand_format_tokens(format, parts); + let has_batch_token = format.contains("{batch_index}"); + if !has_batch_token { + if let Some(index) = parts.maybe_batch_index { + stem.push_str(&format!("_{index}")); + } + } + stem + } + }; + + let stem = sanitize(&stem); + let extension = sanitize(parts.extension); + + format!("{stem}.{extension}") + } + + /// Vet a user-supplied custom format string. + pub fn validate_custom_format(format: &str) -> Result<(), String> { + if format.trim().is_empty() { + return Err("Filename format cannot be empty".to_string()); + } + if format.contains("..") { + return Err("Filename format cannot contain '..'".to_string()); + } + if let Some(bad) = format.chars().find(|c| UNSAFE_CHARACTERS.contains(c) || c.is_control()) { + return Err(format!("Filename format cannot contain {bad:?}")); + } + Ok(()) + } +} + +/// Derive a friendly model slug from a `TaskModelType` string value: +/// version markers like "2p0" become "2.0" (eg. "seedance_2p0" -> +/// "seedance_2.0", "flux_pro_1p1" -> "flux_pro_1.1"). +pub fn model_slug_from_model_type_str(model_type: &str) -> String { + let mut slug = String::with_capacity(model_type.len()); + let chars = model_type.chars().collect::>(); + for (i, c) in chars.iter().enumerate() { + let is_version_dot = *c == 'p' + && i > 0 + && i + 1 < chars.len() + && chars[i - 1].is_ascii_digit() + && chars[i + 1].is_ascii_digit(); + slug.push(if is_version_dot { '.' } else { *c }); + } + slug +} + +fn full_date_string(time: &DateTime) -> String { + format!( + "{:04}-{:02}-{:02}-{:02}-{:02}-{:02}", + time.year(), time.month(), time.day(), + time.hour(), time.minute(), time.second(), + ) +} + +fn expand_format_tokens(format: &str, parts: &DownloadFilenameParts<'_>) -> String { + let time = &parts.download_time; + format + .replace("{model}", parts.model_slug) + .replace("{date}", &full_date_string(time)) + .replace("{YYYY}", &format!("{:04}", time.year())) + .replace("{YY}", &format!("{:02}", time.year() % 100)) + .replace("{MM}", &format!("{:02}", time.month())) + .replace("{DD}", &format!("{:02}", time.day())) + .replace("{HH}", &format!("{:02}", time.hour())) + .replace("{mm}", &format!("{:02}", time.minute())) + .replace("{SS}", &format!("{:02}", time.second())) + .replace( + "{batch_index}", + &parts.maybe_batch_index.map(|i| i.to_string()).unwrap_or_default(), + ) +} + +/// Strip anything unsafe out of a rendered filename component. +fn sanitize(value: &str) -> String { + value.chars() + .filter(|c| !UNSAFE_CHARACTERS.contains(c) && !c.is_control()) + .collect::() + .replace("..", "_") + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn test_time() -> DateTime { + Local.with_ymd_and_hms(2026, 8, 5, 14, 30, 9).unwrap() + } + + mod artcraft_convention { + use super::*; + + #[test] + fn single_file() { + let name = PreferredDownloadFilename::ArtcraftConvention.build_filename(&DownloadFilenameParts { + model_slug: "seedance_2.0", + download_time: test_time(), + maybe_batch_index: None, + extension: "mp4", + }); + assert_eq!(name, "seedance_2.0_2026-08-05-14-30-09.mp4"); + } + + #[test] + fn batch_file() { + let name = PreferredDownloadFilename::ArtcraftConvention.build_filename(&DownloadFilenameParts { + model_slug: "flux_pro_1.1", + download_time: test_time(), + maybe_batch_index: Some(3), + extension: "png", + }); + assert_eq!(name, "flux_pro_1.1_2026-08-05-14-30-09_3.png"); + } + } + + mod custom_format { + use super::*; + + #[test] + fn tokens_expand() { + let format = PreferredDownloadFilename::Custom("{model}-{YYYY}{MM}{DD}-{HH}{mm}{SS}".to_string()); + let name = format.build_filename(&DownloadFilenameParts { + model_slug: "nano_banana", + download_time: test_time(), + maybe_batch_index: None, + extension: "jpg", + }); + assert_eq!(name, "nano_banana-20260805-143009.jpg"); + } + + #[test] + fn batch_index_token() { + let format = PreferredDownloadFilename::Custom("gen_{batch_index}".to_string()); + let name = format.build_filename(&DownloadFilenameParts { + model_slug: "unused", + download_time: test_time(), + maybe_batch_index: Some(2), + extension: "png", + }); + assert_eq!(name, "gen_2.png"); + } + + #[test] + fn batch_index_appended_when_absent_from_format() { + let format = PreferredDownloadFilename::Custom("gen".to_string()); + let name = format.build_filename(&DownloadFilenameParts { + model_slug: "unused", + download_time: test_time(), + maybe_batch_index: Some(2), + extension: "png", + }); + assert_eq!(name, "gen_2.png"); + } + + #[test] + fn unsafe_characters_stripped_from_output() { + let format = PreferredDownloadFilename::Custom("weird{model}".to_string()); + let name = format.build_filename(&DownloadFilenameParts { + model_slug: "a/b\\c\"d", + download_time: test_time(), + maybe_batch_index: None, + extension: "png", + }); + assert_eq!(name, "weirdabcd.png"); + } + } + + mod validation { + use super::*; + + #[test] + fn accepts_reasonable_formats() { + assert!(PreferredDownloadFilename::validate_custom_format("{model}_{date}").is_ok()); + assert!(PreferredDownloadFilename::validate_custom_format("my file {YYYY}").is_ok()); + } + + #[test] + fn rejects_unsafe_formats() { + assert!(PreferredDownloadFilename::validate_custom_format("").is_err()); + assert!(PreferredDownloadFilename::validate_custom_format("a/b").is_err()); + assert!(PreferredDownloadFilename::validate_custom_format("a\\b").is_err()); + assert!(PreferredDownloadFilename::validate_custom_format("a\"b").is_err()); + assert!(PreferredDownloadFilename::validate_custom_format("a'b").is_err()); + assert!(PreferredDownloadFilename::validate_custom_format("../etc").is_err()); + } + } + + mod model_slug { + use super::*; + + #[test] + fn version_markers_become_dots() { + assert_eq!(model_slug_from_model_type_str("seedance_2p0"), "seedance_2.0"); + assert_eq!(model_slug_from_model_type_str("flux_pro_1p1"), "flux_pro_1.1"); + assert_eq!(model_slug_from_model_type_str("seedream_5p0_pro_u"), "seedream_5.0_pro_u"); + } + + #[test] + fn non_version_names_unchanged() { + assert_eq!(model_slug_from_model_type_str("nano_banana"), "nano_banana"); + assert_eq!(model_slug_from_model_type_str("midjourney_8"), "midjourney_8"); + assert_eq!(model_slug_from_model_type_str("marble_1p1_plus"), "marble_1.1_plus"); + } + } + + mod serialization { + use super::*; + + #[test] + fn artcraft_convention_serializes_as_string() { + let val = PreferredDownloadFilename::ArtcraftConvention; + assert_eq!(serde_json::to_string(&val).unwrap(), "\"artcraft_convention\""); + } + + #[test] + fn custom_serializes_with_custom_format_key() { + let val = PreferredDownloadFilename::Custom("{model}_{date}".to_string()); + assert_eq!(serde_json::to_string(&val).unwrap(), "{\"custom_format\":\"{model}_{date}\"}"); + } + + #[test] + fn round_trips() { + for val in [ + PreferredDownloadFilename::ArtcraftConvention, + PreferredDownloadFilename::Custom("{model}".to_string()), + ] { + let json = serde_json::to_string(&val).unwrap(); + let back: PreferredDownloadFilename = serde_json::from_str(&json).unwrap(); + assert_eq!(back, val); + } + } + } +} diff --git a/crates/artcraftx/src/state/mod.rs b/crates/artcraftx/src/state/mod.rs index 6e9bc8d1..2acd7102 100644 --- a/crates/artcraftx/src/state/mod.rs +++ b/crates/artcraftx/src/state/mod.rs @@ -2,5 +2,6 @@ pub mod app_preferences; pub mod artcraft_usage_tracker; pub mod data_dir; pub mod database; +pub mod downloads; pub mod runtime; pub mod window; diff --git a/crates/artcraftx/src/utils/download_url_to_download_dir_via_temp.rs b/crates/artcraftx/src/utils/download_url_to_download_dir_via_temp.rs index f8a51f1e..fa76c23e 100644 --- a/crates/artcraftx/src/utils/download_url_to_download_dir_via_temp.rs +++ b/crates/artcraftx/src/utils/download_url_to_download_dir_via_temp.rs @@ -1,7 +1,7 @@ use crate::error::artcraftx_error::ArtcraftXError; use crate::state::app_preferences::app_preferences::AppPreferences; use crate::state::data_dir::app_data_root::AppDataRoot; -use crate::utils::download_url_to_user_download_dir::download_file_name_from_url; +use crate::utils::download_url_to_user_download_dir::{check_download_file_name, download_file_name_from_url}; use anyhow::anyhow; use log::info; use std::io::Write; @@ -12,15 +12,26 @@ use url::Url; /// configured download directory. Downloading to temp first means partially /// downloaded files never land in the download directory. /// +/// `maybe_filename` overrides the URL-derived filename (eg. to apply the +/// user's preferred download filename convention). It is vetted with the +/// same rules as URL-derived names. +/// /// Returns the final path. If a file with the same name already exists in the /// download directory, nothing is downloaded and /// [`ArtcraftXError::CannotDownloadFilePathAlreadyExists`] is returned. pub async fn download_url_to_download_dir_via_temp( url: &Url, + maybe_filename: Option<&str>, app_data_root: &AppDataRoot, app_prefs: &AppPreferences, ) -> Result { - let url_file_name = download_file_name_from_url(url)?; + let url_file_name = match maybe_filename { + Some(filename) => { + check_download_file_name(filename)?; + filename.to_string() + } + None => download_file_name_from_url(url)?, + }; let download_directory = app_prefs .preferred_download_directory @@ -90,7 +101,7 @@ mod tests { // A known completed generation output. let url = Url::parse("https://cdn-2.fakeyou.com/media/j/q/z/s/x/jqzsxxd8r20bnx42jw2dhdj6tq81q9xf/artcraft_jqzsxxd8r20bnx42jw2dhdj6tq81q9xf.png").unwrap(); - let path = download_url_to_download_dir_via_temp(&url, &app_data_root, &app_prefs) + let path = download_url_to_download_dir_via_temp(&url, None, &app_data_root, &app_prefs) .await .expect("download should succeed"); @@ -100,7 +111,7 @@ mod tests { assert!(path.starts_with(app_prefs.preferred_download_directory.download_directory(&app_data_root))); // Second attempt must refuse to clobber. - let second = download_url_to_download_dir_via_temp(&url, &app_data_root, &app_prefs).await; + let second = download_url_to_download_dir_via_temp(&url, None, &app_data_root, &app_prefs).await; assert!(matches!(second, Err(ArtcraftXError::CannotDownloadFilePathAlreadyExists { .. }))); std::fs::remove_file(&path).expect("cleanup"); diff --git a/crates/artcraftx/src/utils/download_url_to_user_download_dir.rs b/crates/artcraftx/src/utils/download_url_to_user_download_dir.rs index e54b71af..1f525d66 100644 --- a/crates/artcraftx/src/utils/download_url_to_user_download_dir.rs +++ b/crates/artcraftx/src/utils/download_url_to_user_download_dir.rs @@ -74,13 +74,15 @@ pub fn download_file_name_from_url(url: &Url) -> Result url_file_name = format!("artcraft_{}", url_file_name); } - check_url_file_name_for_downloadability(&url_file_name)?; + check_download_file_name(&url_file_name)?; Ok(url_file_name) } +/// Vet a filename (from any source) before writing it into the download +/// directory. // TODO: This likely needs more protections. -fn check_url_file_name_for_downloadability(filename: &str) -> Result<(), ArtcraftXError> { +pub fn check_download_file_name(filename: &str) -> Result<(), ArtcraftXError> { if filename.trim().is_empty() { error!("Download filename is empty!"); return Err(ArtcraftXError::BadDownloadFilename { path: "".into() }.into()); diff --git a/frontend/apps/artcraft/app/src/components/PromptShell/PromptShell.tsx b/frontend/apps/artcraft/app/src/components/PromptShell/PromptShell.tsx index 2f1a34ee..56c766b2 100644 --- a/frontend/apps/artcraft/app/src/components/PromptShell/PromptShell.tsx +++ b/frontend/apps/artcraft/app/src/components/PromptShell/PromptShell.tsx @@ -36,8 +36,11 @@ export function PromptShell({ const [receipt, setReceipt] = useState(null); const savedIdsRef = useRef>(new Set()); - // Save each completed file exactly once. The Rust side already flashes a - // toast if a download fails, so errors only suppress the receipt here. + // Save each completed file exactly once. ArtCraft-provider files are + // already auto-saved to disk by the Rust polling thread (named per the + // user's filename convention), so they only get receipted here — + // downloading them again would create duplicates. The Rust side already + // flashes a toast if a download fails, so errors only suppress the receipt. useEffect(() => { if (!completed?.length) return; const fresh = completed.filter((f) => !savedIdsRef.current.has(f.id)); @@ -47,7 +50,12 @@ export function PromptShell({ let cancelled = false; (async () => { const savedNames: string[] = []; + let savedOnDiskCount = 0; for (const file of fresh) { + if (file.provider === "artcraft") { + savedOnDiskCount += 1; + continue; + } try { await DownloadUrl(file.url); savedNames.push(fileNameFromUrl(file.url)); @@ -55,12 +63,15 @@ export function PromptShell({ // Rust flashed the failure toast; nothing to receipt. } } - if (cancelled || savedNames.length === 0) return; + const total = savedNames.length + savedOnDiskCount; + if (cancelled || total === 0) return; setReceipt({ text: - savedNames.length === 1 + savedNames.length === 1 && savedOnDiskCount === 0 ? savedNames[0] - : `${savedNames.length} files`, + : total === 1 + ? "1 file" + : `${total} files`, key: Date.now(), }); })(); diff --git a/frontend/apps/artcraft/app/src/components/PromptShell/useComposerTasks.ts b/frontend/apps/artcraft/app/src/components/PromptShell/useComposerTasks.ts index 4fcddbb8..b09161fa 100644 --- a/frontend/apps/artcraft/app/src/components/PromptShell/useComposerTasks.ts +++ b/frontend/apps/artcraft/app/src/components/PromptShell/useComposerTasks.ts @@ -16,6 +16,10 @@ export interface CompletedFile { /** Unique per file (task id, or task id + batch index). */ id: string; url: string; + /** Generation provider ("artcraft", "fal", ...) — ArtCraft files are + * auto-saved by the Rust polling thread, so the frontend must not + * download them again. */ + provider?: string; } const TASK_TYPES: Record> = { @@ -112,9 +116,11 @@ async function expandTaskFiles( t: Awaited>["tasks"][number], mediaFilesApi: MediaFilesApi, ): Promise { + const provider = t.provider ? String(t.provider) : undefined; const primary: CompletedFile = { id: t.id, url: t.completed_item!.primary_media_file.cdn_url, + provider, }; const batchToken = t.completed_item?.maybe_batch_token; @@ -131,7 +137,7 @@ async function expandTaskFiles( .map((file: any, index: number): CompletedFile | null => { const cdnUrl = file.media_links?.cdn_url; if (!cdnUrl) return null; - return { id: `${t.id}:${file.token ?? index}`, url: cdnUrl }; + return { id: `${t.id}:${file.token ?? index}`, url: cdnUrl, provider }; }) .filter((f: CompletedFile | null): f is CompletedFile => f !== null); return files.length > 0 ? files : [primary]; diff --git a/frontend/libs/components/settings-modal/src/lib/panes/DownloadsSettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/DownloadsSettingsPane.tsx new file mode 100644 index 00000000..6e20fe8c --- /dev/null +++ b/frontend/libs/components/settings-modal/src/lib/panes/DownloadsSettingsPane.tsx @@ -0,0 +1,232 @@ +import { useEffect, useState } from "react"; +import { Button } from "@storyteller/ui-button"; +import { + AppPreferencesPayload, + CustomDirectory, + CustomFilenameFormat, + GetAppPreferences, + PreferredDownloadFilename, + SystemDirectory, +} from "@storyteller/tauri-api"; +import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api"; +import { open } from "@tauri-apps/plugin-dialog"; +import { Switch } from "@storyteller/ui-switch"; +import { DownloadDirectoryReveal } from "@storyteller/tauri-api"; +import { Folder, RotateCcw, Search } from "lucide-react"; +import { Select, SelectValue } from "@storyteller/ui-select"; +import { SettingsBlock, SettingsRow } from "./SettingsRow"; +import { + getAskLocationBeforeDownload, + setAskLocationBeforeDownload, +} from "@storyteller/api"; + +const ARTCRAFT_CONVENTION = "artcraft_convention"; +const CUSTOM = "custom"; + +const DEFAULT_CUSTOM_FORMAT = "{model}_{date}"; + +// Characters the backend rejects in custom formats. +const UNSAFE_FORMAT_CHARACTERS = /[/\\'"`%<>|:*?]/; + +const FILENAME_OPTIONS = [ + { value: ARTCRAFT_CONVENTION, label: "ArtCraft convention" }, + { value: CUSTOM, label: "Custom format" }, +]; + +interface DownloadsSettingsPaneProps {} + +export const DownloadsSettingsPane = (args: DownloadsSettingsPaneProps) => { + const [preferences, setPreferences] = useState< + AppPreferencesPayload | undefined + >(undefined); + + const [filenameMode, setFilenameMode] = useState(ARTCRAFT_CONVENTION); + const [customFormat, setCustomFormat] = useState(DEFAULT_CUSTOM_FORMAT); + const [formatError, setFormatError] = useState(null); + + const [askLocationBeforeDownload, setAskLocationBeforeDownloadState] = + useState(() => getAskLocationBeforeDownload()); + + const toggleAskLocationBeforeDownload = (enabled: boolean) => { + setAskLocationBeforeDownload(enabled); + setAskLocationBeforeDownloadState(enabled); + }; + + const applyPreferences = (prefs: AppPreferencesPayload) => { + setPreferences(prefs); + const filename: PreferredDownloadFilename | undefined = + prefs.preferred_download_filename; + if (filename && typeof filename === "object" && "custom_format" in filename) { + setFilenameMode(CUSTOM); + setCustomFormat(filename.custom_format); + } else { + setFilenameMode(ARTCRAFT_CONVENTION); + } + }; + + useEffect(() => { + const fetchData = async () => { + const prefs = await GetAppPreferences(); + applyPreferences(prefs.preferences); + }; + fetchData(); + }, []); + + const reloadPreferences = async () => { + const prefs = await GetAppPreferences(); + applyPreferences(prefs.preferences); + }; + + // NB: This might be a complex type. + const outerDownloadObject = preferences?.preferred_download_directory || {}; + const downloadDirectory = + "custom" in outerDownloadObject + ? (outerDownloadObject.custom as string) + : ""; + const currentDownloadLabel = + "system" in outerDownloadObject + ? "System Download Directory" + : downloadDirectory; + + const openDirectoryPicker = async () => { + let directory = await open({ + multiple: false, + directory: true, + defaultPath: downloadDirectory || undefined, + }); + if (directory === null) { + return; // User dismissed the dialog choice + } + await UpdateAppPreferences({ + preference: PreferenceName.PreferredDownloadDirectory, + value: { + custom: directory, + } as CustomDirectory, + }); + await reloadPreferences(); + }; + + const clearDirectory = async () => { + await UpdateAppPreferences({ + preference: PreferenceName.PreferredDownloadDirectory, + value: { + system: "downloads", + } as SystemDirectory, + }); + await reloadPreferences(); + }; + + const showDirectory = async () => { + await DownloadDirectoryReveal(); + }; + + const saveFilenamePreference = async ( + value: PreferredDownloadFilename, + ): Promise => { + try { + await UpdateAppPreferences({ + preference: PreferenceName.PreferredDownloadFilename, + value, + }); + await reloadPreferences(); + return true; + } catch (error) { + setFormatError(String(error)); + return false; + } + }; + + const changeFilenameMode = async (mode: string) => { + setFilenameMode(mode); + setFormatError(null); + if (mode === ARTCRAFT_CONVENTION) { + await saveFilenamePreference(ARTCRAFT_CONVENTION); + } + // Custom mode saves on "Save format" so half-typed formats never persist. + }; + + const saveCustomFormat = async () => { + const format = customFormat.trim(); + if (!format) { + setFormatError("Format cannot be empty"); + return; + } + if (UNSAFE_FORMAT_CHARACTERS.test(format) || format.includes("..")) { + setFormatError("Format cannot contain slashes, quotes, or other unsafe characters"); + return; + } + setFormatError(null); + await saveFilenamePreference({ custom_format: format } as CustomFilenameFormat); + }; + + return ( +
+ +
+ {currentDownloadLabel} +
+
+ + + +
+
+ +
+ setCustomFormat(e.target.value)} + placeholder={DEFAULT_CUSTOM_FORMAT} + spellCheck={false} + className="w-72 rounded-ax-sm border border-line bg-well/60 px-3 py-2 font-mono text-[12px] text-base-fg outline-none focus:border-putty" + /> + +
+

+ {"Tokens: {model}, {date}, {YYYY}, {YY}, {MM}, {DD}, {HH}, {mm}, {SS}, {batch_index}. The file extension is added automatically."} +

+ {formatError && ( +

{formatError}

+ )} +
+ )} + + + + + + ); +}; diff --git a/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx index fa1bd3da..285268d5 100644 --- a/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx +++ b/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx @@ -1,132 +1,15 @@ -import { useEffect, useState } from "react"; -import { Button } from "@storyteller/ui-button"; -import { - AppPreferencesPayload, - CustomDirectory, - GetAppPreferences, - SystemDirectory, -} from "@storyteller/tauri-api"; -import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api"; -import { open } from "@tauri-apps/plugin-dialog"; -import { Switch } from "@storyteller/ui-switch"; -import { DownloadDirectoryReveal } from "@storyteller/tauri-api"; -import { Folder, RotateCcw, Search } from "lucide-react"; import { useEnterToGenerateStore } from "@storyteller/ui-promptbox"; -import { SettingsBlock, SettingsRow } from "./SettingsRow"; -import { - getAskLocationBeforeDownload, - setAskLocationBeforeDownload, -} from "@storyteller/api"; +import { Switch } from "@storyteller/ui-switch"; +import { SettingsRow } from "./SettingsRow"; interface MiscSettingsPaneProps {} export const MiscSettingsPane = (args: MiscSettingsPaneProps) => { - const [preferences, setPreferences] = useState< - AppPreferencesPayload | undefined - >(undefined); - const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled); - const [askLocationBeforeDownload, setAskLocationBeforeDownloadState] = - useState(() => getAskLocationBeforeDownload()); - - const toggleAskLocationBeforeDownload = (enabled: boolean) => { - setAskLocationBeforeDownload(enabled); - setAskLocationBeforeDownloadState(enabled); - }; - - useEffect(() => { - const fetchData = async () => { - const prefs = await GetAppPreferences(); - console.log("prefs", prefs); - setPreferences(prefs.preferences); - }; - fetchData(); - }, []); - - // NB: This might be a complex type. - const outerDownloadObject = preferences?.preferred_download_directory || {}; - const downloadDirectory = - "custom" in outerDownloadObject - ? (outerDownloadObject.custom as string) - : ""; - const currentDownloadLabel = - "system" in outerDownloadObject - ? "System Download Directory" - : downloadDirectory; - - const reloadPreferences = async () => { - const prefs = await GetAppPreferences(); - console.log("prefs", prefs); - setPreferences(prefs.preferences); - }; - - const openDirectoryPicker = async () => { - let directory = await open({ - multiple: false, - directory: true, - defaultPath: downloadDirectory || undefined, - }); - if (directory === null) { - return; // User dismissed the dialog choice - } - await UpdateAppPreferences({ - preference: PreferenceName.PreferredDownloadDirectory, - value: { - custom: directory, - } as CustomDirectory, - }); - await reloadPreferences(); - }; - - const clearDirectory = async () => { - await UpdateAppPreferences({ - preference: PreferenceName.PreferredDownloadDirectory, - value: { - system: "downloads", - } as SystemDirectory, - }); - await reloadPreferences(); - }; - - const showDirectory = async () => { - await DownloadDirectoryReveal(); - }; - return (
- -
- {currentDownloadLabel} -
-
- - - -
-
- - - ; case "general": return ; + case "downloads": + return ; case "accounts": return (