download settings
This commit is contained in:
parent
d1ac2e6102
commit
f57edc4d39
|
|
@ -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<AppPrefere
|
|||
let prefs = app_prefs.get_clone()?;
|
||||
Ok(AppPreferencesPayload {
|
||||
preferred_download_directory: prefs.preferred_download_directory,
|
||||
preferred_download_filename: prefs.preferred_download_filename,
|
||||
play_sounds: prefs.play_sounds,
|
||||
delete_file_sound: prefs.delete_file_sound,
|
||||
enqueue_success_sound: prefs.enqueue_success_sound,
|
||||
|
|
|
|||
|
|
@ -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 crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use anyhow::anyhow;
|
||||
use errors::AnyhowResult;
|
||||
|
|
@ -15,18 +16,24 @@ pub struct UpdateAppPreferencesRequest {
|
|||
pub value: Option<ValueType>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
|||
_ => 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::<ValueType>("true").unwrap(),
|
||||
ValueType::Bool(true),
|
||||
));
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<ValueType>("\"artcraft_convention\"").unwrap(),
|
||||
ValueType::DownloadFilename(PreferredDownloadFilename::ArtcraftConvention),
|
||||
));
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<ValueType>("{\"custom_format\":\"{model}_{date}\"}").unwrap(),
|
||||
ValueType::DownloadFilename(PreferredDownloadFilename::Custom(_)),
|
||||
));
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<ValueType>("{\"custom\":\"/tmp\"}").unwrap(),
|
||||
ValueType::DownloadDirectory(PreferredDownloadDirectory::Custom(_)),
|
||||
));
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<ValueType>("{\"system\":\"downloads\"}").unwrap(),
|
||||
ValueType::DownloadDirectory(PreferredDownloadDirectory::System(_)),
|
||||
));
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<ValueType>("\"done\"").unwrap(),
|
||||
ValueType::String(_),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>()
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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<PreferredDownloadDirectory>,
|
||||
|
||||
/// How downloaded generation files are named on disk.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub preferred_download_filename: Option<PreferredDownloadFilename>,
|
||||
|
||||
/// Play sounds on events.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub play_sounds: Option<bool>,
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
pub mod app_preferences;
|
||||
pub mod app_preferences_manager;
|
||||
pub mod app_preferences_serializable;
|
||||
pub mod preferred_download_directory;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
pub mod preferred_download_directory;
|
||||
pub mod preferred_download_filename;
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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<Local>,
|
||||
|
||||
/// 1-based index when the job produced more than one file.
|
||||
pub maybe_batch_index: Option<usize>,
|
||||
|
||||
/// 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::<Vec<_>>();
|
||||
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<Local>) -> 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::<String>()
|
||||
.replace("..", "_")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn test_time() -> DateTime<Local> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<PathBuf, ArtcraftXError> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -74,13 +74,15 @@ pub fn download_file_name_from_url(url: &Url) -> Result<String, ArtcraftXError>
|
|||
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());
|
||||
|
|
|
|||
|
|
@ -36,8 +36,11 @@ export function PromptShell({
|
|||
const [receipt, setReceipt] = useState<Receipt | null>(null);
|
||||
const savedIdsRef = useRef<Set<string>>(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(),
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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<ComposerTaskModality, ReadonlySet<string>> = {
|
||||
|
|
@ -112,9 +116,11 @@ async function expandTaskFiles(
|
|||
t: Awaited<ReturnType<typeof GetTaskQueue>>["tasks"][number],
|
||||
mediaFilesApi: MediaFilesApi,
|
||||
): Promise<CompletedFile[]> {
|
||||
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];
|
||||
|
|
|
|||
|
|
@ -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<string>(ARTCRAFT_CONVENTION);
|
||||
const [customFormat, setCustomFormat] = useState<string>(DEFAULT_CUSTOM_FORMAT);
|
||||
const [formatError, setFormatError] = useState<string | null>(null);
|
||||
|
||||
const [askLocationBeforeDownload, setAskLocationBeforeDownloadState] =
|
||||
useState<boolean>(() => 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<boolean> => {
|
||||
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 (
|
||||
<div className="text-base-fg">
|
||||
<SettingsBlock
|
||||
title="Download directory"
|
||||
description="Generated files are written straight to this folder."
|
||||
>
|
||||
<div className="w-full overflow-x-auto rounded-ax-sm border border-line bg-well/60 px-3 py-2 font-mono text-[12px] text-putty">
|
||||
{currentDownloadLabel}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={openDirectoryPicker}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Choose folder
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={showDirectory}>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Show in explorer
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={clearDirectory}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Use default
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsBlock>
|
||||
<SettingsBlock
|
||||
title="Filename convention"
|
||||
description="How downloaded files are named. The ArtCraft convention is {model}_{date}.{ext}, with a batch index when a generation produces several files."
|
||||
>
|
||||
<div className="max-w-xs">
|
||||
<Select
|
||||
options={FILENAME_OPTIONS}
|
||||
value={filenameMode}
|
||||
onChange={(val: SelectValue) => changeFilenameMode(val as string)}
|
||||
/>
|
||||
</div>
|
||||
{filenameMode === CUSTOM && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customFormat}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button variant="secondary" onClick={saveCustomFormat}>
|
||||
Save format
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[12px] text-mud">
|
||||
{"Tokens: {model}, {date}, {YYYY}, {YY}, {MM}, {DD}, {HH}, {mm}, {SS}, {batch_index}. The file extension is added automatically."}
|
||||
</p>
|
||||
{formatError && (
|
||||
<p className="text-[12px] text-red-400">{formatError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsBlock>
|
||||
<SettingsRow
|
||||
title="Ask location before download"
|
||||
description="When on, a file picker appears for every download so you choose where each file goes. When off, downloads go straight to the folder above."
|
||||
>
|
||||
<Switch
|
||||
enabled={askLocationBeforeDownload}
|
||||
setEnabled={toggleAskLocationBeforeDownload}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<boolean>(() => 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 (
|
||||
<div className="text-base-fg">
|
||||
<SettingsBlock
|
||||
title="Download directory"
|
||||
description="Generated files are written straight to this folder."
|
||||
>
|
||||
<div className="w-full overflow-x-auto rounded-ax-sm border border-line bg-well/60 px-3 py-2 font-mono text-[12px] text-putty">
|
||||
{currentDownloadLabel}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={openDirectoryPicker}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Choose folder
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={showDirectory}>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Show in explorer
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={clearDirectory}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Use default
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsBlock>
|
||||
<SettingsRow
|
||||
title="Ask location before download"
|
||||
description="When on, a file picker appears for every download so you choose where each file goes. When off, downloads go straight to the folder above."
|
||||
>
|
||||
<Switch
|
||||
enabled={askLocationBeforeDownload}
|
||||
setEnabled={toggleAskLocationBeforeDownload}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title="Enter to generate"
|
||||
description="When on, Enter submits the prompt and Shift+Enter adds a new line. When off, both add a new line — use the button to submit."
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Modal } from "@storyteller/ui-modal";
|
|||
import { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { MiscSettingsPane } from "./panes/MiscSettingsPane";
|
||||
import { DownloadsSettingsPane } from "./panes/DownloadsSettingsPane";
|
||||
import { AudioSettingsPane } from "./panes/AudioSettingsPane";
|
||||
import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane";
|
||||
import { AboutSettingsPane } from "./panes/AboutSettingsPane";
|
||||
|
|
@ -15,6 +16,7 @@ import { ExperimentalConfirmModal } from "./ExperimentalConfirmModal";
|
|||
|
||||
export type SettingsSection =
|
||||
| "general"
|
||||
| "downloads"
|
||||
| "appearance"
|
||||
| "accounts"
|
||||
| "alerts"
|
||||
|
|
@ -58,6 +60,7 @@ export const SettingsContent = ({
|
|||
|
||||
const sections = [
|
||||
{ id: "general" as const, label: "General" },
|
||||
{ id: "downloads" as const, label: "Downloads" },
|
||||
{ id: "accounts" as const, label: "Accounts" },
|
||||
{ id: "billing" as const, label: "Plan & Credits" },
|
||||
{ id: "alerts" as const, label: "Alerts" },
|
||||
|
|
@ -75,6 +78,8 @@ export const SettingsContent = ({
|
|||
return <AudioSettingsPane />;
|
||||
case "general":
|
||||
return <MiscSettingsPane />;
|
||||
case "downloads":
|
||||
return <DownloadsSettingsPane />;
|
||||
case "accounts":
|
||||
return (
|
||||
<AccountSettingsPane
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ export interface AppPreferencesPayload {
|
|||
// Preferred download directory
|
||||
preferred_download_directory: PreferredDownloadDirectory,
|
||||
|
||||
// How downloaded generation files are named on disk.
|
||||
preferred_download_filename: PreferredDownloadFilename,
|
||||
|
||||
// Play sounds on events.
|
||||
play_sounds: boolean,
|
||||
|
||||
|
|
@ -23,6 +26,15 @@ export interface AppPreferencesPayload {
|
|||
|
||||
export type PreferredDownloadDirectory = SystemDirectory | CustomDirectory;
|
||||
|
||||
// "artcraft_convention" or a custom format string wrapper.
|
||||
// Custom formats accept {model}, {date}, {YYYY}, {YY}, {MM}, {DD}, {HH},
|
||||
// {mm}, {SS}, and {batch_index} tokens.
|
||||
export type PreferredDownloadFilename = "artcraft_convention" | CustomFilenameFormat;
|
||||
|
||||
export interface CustomFilenameFormat {
|
||||
custom_format: string,
|
||||
}
|
||||
|
||||
export interface SystemDirectory {
|
||||
// If the directory is a system directory.
|
||||
system: string,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { PreferredDownloadDirectory } from "./GetAppPreferences";
|
||||
import { PreferredDownloadDirectory, PreferredDownloadFilename } from "./GetAppPreferences";
|
||||
|
||||
export interface UpdateAppPreferencesRequest {
|
||||
preference: PreferenceName,
|
||||
value: undefined | string | boolean | PreferredDownloadDirectory,
|
||||
value: undefined | string | boolean | PreferredDownloadDirectory | PreferredDownloadFilename,
|
||||
}
|
||||
|
||||
export enum PreferenceName {
|
||||
PreferredDownloadDirectory = "preferred_download_directory",
|
||||
PreferredDownloadFilename = "preferred_download_filename",
|
||||
PlaySounds = "play_sounds",
|
||||
DeleteFileSound = "delete_file_sound",
|
||||
EnqueueSuccessSound = "enqueue_success_sound",
|
||||
|
|
|
|||
Loading…
Reference in New Issue