Skip to main content

max / audiofiles

4.3 KB · 124 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 && overrides.len() == items.len() {
24 return overrides.clone();
25 }
26
27 let output_ext = match config.format {
28 ExportFormat::Wav => "wav",
29 ExportFormat::Aiff => "aiff",
30 ExportFormat::Original => "",
31 };
32
33 let mut names: Vec<String> = if let Some(pat) = pattern {
34 let contexts: Vec<RenameContext> = items
35 .iter()
36 .enumerate()
37 .map(|(i, item)| {
38 let (stem, ext) = split_name_ext(&item.name);
39 RenameContext {
40 name: stem,
41 extension: if output_ext.is_empty() {
42 ext
43 } else {
44 output_ext.to_string()
45 },
46 bpm: item.bpm,
47 musical_key: item.musical_key.clone(),
48 classification: item.classification.clone(),
49 duration: item.duration,
50 index: i,
51 }
52 })
53 .collect();
54
55 let stems = pat.resolve_all(&contexts);
56 stems
57 .into_iter()
58 .zip(items.iter())
59 .map(|(stem, item)| {
60 let ext = if !output_ext.is_empty() {
61 output_ext
62 } else {
63 &item.ext
64 };
65 let stem = if stem.is_empty() {
66 item.name.split('.').next().unwrap_or("untitled").to_string()
67 } else {
68 stem
69 };
70 format!("{stem}.{ext}")
71 })
72 .collect()
73 } else {
74 // Use original names, changing extension if converting
75 items
76 .iter()
77 .map(|item| {
78 if !output_ext.is_empty() {
79 let (stem, _) = split_name_ext(&item.name);
80 format!("{stem}.{output_ext}")
81 } else {
82 item.name.clone()
83 }
84 })
85 .collect()
86 };
87
88 // Apply naming rules (sanitize stems) if set
89 if let Some(ref rules) = config.naming_rules {
90 for name in &mut names {
91 let (stem, ext) = split_name_ext(name);
92 let sanitized = sanitize::sanitize_filename(&stem, rules);
93 let sanitized = if sanitized.is_empty() { "untitled".to_string() } else { sanitized };
94 *name = format!("{sanitized}.{ext}");
95 }
96 }
97
98 // Strip path separators and NUL bytes from all filenames to prevent path traversal,
99 // even when no NamingRules are configured.
100 for name in &mut names {
101 *name = name.replace(['/', '\\', '\0'], "_");
102 // Reject . and .. as bare stems (after extension split these would be just dots)
103 let stem_part = name.split('.').next().unwrap_or("");
104 if stem_part == ".." {
105 *name = name.replacen("..", "_", 1);
106 }
107 }
108
109 // Deduplicate: same-named files (from different VFS dirs, or after sanitization)
110 // would silently overwrite each other in flat export. Append _2, _3, etc.
111 let mut seen = std::collections::HashMap::<String, usize>::new();
112 for name in &mut names {
113 let lower = name.to_lowercase();
114 let count = seen.entry(lower).or_insert(0);
115 *count += 1;
116 if *count > 1 {
117 let (stem, ext) = split_name_ext(name);
118 *name = format!("{stem}_{}.{ext}", count);
119 }
120 }
121
122 names
123 }
124