Batch: a folder source's file name keeps a backslash on Linux and macOS

Output stems for a folder batch went through safe_file_name, which turns
`\` and `:` into `-`, so `a\b.svg` on Linux was written as `a-b.svg`.
The stem now only loses trailing periods and spaces and gets `-` after a
Windows device name, and keeps every other character.
This commit is contained in:
dotCooCoo
2026-11-01 06:35:40 -08:00
parent 82c04ff35a
commit a4436388cc
2 changed files with 31 additions and 7 deletions
+24 -6
View File
@@ -102,7 +102,6 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result<Value> {
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<Value> {
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");
+7 -1
View File
@@ -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.