diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 0b85ab9df..c34bfd854 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -102,7 +102,6 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { - use super::screens::safe_file_name; use super::{absolute_path, may_read}; let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; @@ -115,9 +114,9 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: if files.is_empty() { return Err(bad(cmd, "no readable files in the folder")); } - // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the - // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and - // `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file + // Each file's name without its extension, through [`source_stem`] (`a..svg` gives `a`, the + // folder Windows makes for `a.`, and `a\b.svg` keeps `a\b` where `\` is not a separator), with + // `-2`, `-3`… after a name an earlier file used (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file // wrote ([`distinct_stems`]), as data set names are numbered. No output takes the name of a // source file in the destination folder, other than that file's own output, or of a subfolder // of the destination folder that holds source files. @@ -127,10 +126,10 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: let reserved = in_dest.iter().flatten().map(|(name, _)| name.clone()).collect(); let stems = distinct_stems( files.iter().zip(&in_dest).map(|(path, place)| { - let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let stem = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); let (exts, makes_folder) = outputs(&run, &super::extension(path)); let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); - (if stem.is_empty() { "file".to_string() } else { stem }, exts, makes_folder, own) + (stem, exts, makes_folder, own) }), reserved, ); @@ -183,6 +182,16 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false})) } +/// The output stem for a source file's name without its extension: without trailing periods and +/// spaces and with `-` after a Windows device name ([`super::screens::name_without_device`]), and +/// "file" when nothing is left. Other characters stay, so `a\b` and `a:b` keep their names on Linux +/// and macOS. +#[cfg(not(target_arch = "wasm32"))] +fn source_stem(stem: &str) -> String { + let stem = super::screens::name_without_device(stem); + if stem.is_empty() { "file".to_string() } else { stem } +} + /// What one source writes into the destination folder: the extension of each file a Save or /// Export step or the Folder destination writes there as `{stem}.{ext}`, and whether an Export for /// Screens or Export Assets step makes the folder `{stem}` there. Follows [`rewrite_paths`] and @@ -748,6 +757,15 @@ mod tests { assert_eq!(file_names(&dest), ["a\\b.svg"], "{r}"); } + /// A source stem keeps `\`, `:` and the other characters Windows does not allow in a name, which + /// only reach the batch where the file system allows them, and loses trailing periods and spaces. + #[test] + fn source_stem_keeps_characters_the_file_system_allowed() { + for (stem, want) in [("a\\b", "a\\b"), ("a:b", "a:b"), ("a.", "a"), ("a. ", "a"), ("CON", "CON-"), ("...", "file"), ("", "file")] { + assert_eq!(source_stem(stem), want, "{stem:?}"); + } + } + #[test] fn output_path_accepts_only_platform_file_name_separators() { let folder = tmp("output-path"); diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 966038b35..bb5bd0acf 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -174,7 +174,13 @@ pub(super) fn file_part(s: &str) -> String { /// every platform. Spaces between the device name and the period are dropped (`CON .png` becomes /// `CON-.png`). pub(crate) fn safe_file_name(s: &str) -> String { - let name = file_part(s); + name_without_device(&file_part(s)) +} + +/// [`safe_file_name`] without [`file_part`]: drops trailing periods and spaces and puts `-` after a +/// Windows device name, and keeps every other character, so a source file name with `\` or `:` keeps +/// it where the file system allows it. +pub(super) fn name_without_device(name: &str) -> String { let name = name.trim_end_matches(['.', ' ']); let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len())); // Windows ignores spaces before the extension, so `CON .png` is the CON device too.