Skip to main content

max / audiofiles

4.3 KB · 125 lines History Blame Raw
1 //! Output filename resolution: rename patterns, sanitization, deduplication.
2
3 use crate::rename::{RenameContext, RenamePattern};
4 use crate::util::split_name_ext;
5
6 use super::sanitize;
7 use super::{ExportConfig, ExportFormat, ExportItem};
8 use tracing::instrument;
9
10 /// Resolve output filenames for all items, applying rename pattern, sanitization, and deduplication.
11 ///
12 /// If `config.name_overrides` is set, those names are returned directly (used by the backend
13 /// to inject hook-transformed names). Otherwise, names are computed from the rename pattern,
14 /// original item names, naming rules, and deduplication.
15 #[instrument(skip_all)]
16 pub fn resolve_output_names(
17 items: &[ExportItem],
18 config: &ExportConfig,
19 pattern: Option<&RenamePattern>,
20 ) -> Vec<String> {
21 // If the backend pre-computed names (e.g. via transform_filename hook), use them directly.
22 if let Some(ref overrides) = config.name_overrides {
23 if overrides.len() == items.len() {
24 return overrides.clone();
25 }
26 }
27
28 let output_ext = match config.format {
29 ExportFormat::Wav => "wav",
30 ExportFormat::Aiff => "aiff",
31 ExportFormat::Original => "",
32 };
33
34 let mut names: Vec<String> = if let Some(pat) = pattern {
35 let contexts: Vec<RenameContext> = items
36 .iter()
37 .enumerate()
38 .map(|(i, item)| {
39 let (stem, ext) = split_name_ext(&item.name);
40 RenameContext {
41 name: stem,
42 extension: if output_ext.is_empty() {
43 ext
44 } else {
45 output_ext.to_string()
46 },
47 bpm: item.bpm,
48 musical_key: item.musical_key.clone(),
49 classification: item.classification.clone(),
50 duration: item.duration,
51 index: i,
52 }
53 })
54 .collect();
55
56 let stems = pat.resolve_all(&contexts);
57 stems
58 .into_iter()
59 .zip(items.iter())
60 .map(|(stem, item)| {
61 let ext = if !output_ext.is_empty() {
62 output_ext
63 } else {
64 &item.ext
65 };
66 let stem = if stem.is_empty() {
67 item.name.split('.').next().unwrap_or("untitled").to_string()
68 } else {
69 stem
70 };
71 format!("{stem}.{ext}")
72 })
73 .collect()
74 } else {
75 // Use original names, changing extension if converting
76 items
77 .iter()
78 .map(|item| {
79 if !output_ext.is_empty() {
80 let (stem, _) = split_name_ext(&item.name);
81 format!("{stem}.{output_ext}")
82 } else {
83 item.name.clone()
84 }
85 })
86 .collect()
87 };
88
89 // Apply naming rules (sanitize stems) if set
90 if let Some(ref rules) = config.naming_rules {
91 for name in &mut names {
92 let (stem, ext) = split_name_ext(name);
93 let sanitized = sanitize::sanitize_filename(&stem, rules);
94 let sanitized = if sanitized.is_empty() { "untitled".to_string() } else { sanitized };
95 *name = format!("{sanitized}.{ext}");
96 }
97 }
98
99 // Strip path separators and NUL bytes from all filenames to prevent path traversal,
100 // even when no NamingRules are configured.
101 for name in &mut names {
102 *name = name.replace(['/', '\\', '\0'], "_");
103 // Reject . and .. as bare stems (after extension split these would be just dots)
104 let stem_part = name.split('.').next().unwrap_or("");
105 if stem_part == ".." {
106 *name = name.replacen("..", "_", 1);
107 }
108 }
109
110 // Deduplicate: same-named files (from different VFS dirs, or after sanitization)
111 // would silently overwrite each other in flat export. Append _2, _3, etc.
112 let mut seen = std::collections::HashMap::<String, usize>::new();
113 for name in &mut names {
114 let lower = name.to_lowercase();
115 let count = seen.entry(lower).or_insert(0);
116 *count += 1;
117 if *count > 1 {
118 let (stem, ext) = split_name_ext(name);
119 *name = format!("{stem}_{}.{ext}", count);
120 }
121 }
122
123 names
124 }
125