1use std::ffi::OsString;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use serde_json::{Map, Value};
10use xil_core::fsutil::{abspath, basename, glob_children};
11use xil_core::pycsv;
12use xil_core::pyjson::{dumps, Style};
13use xil_core::workspace::workspace_root;
14use xil_core::{banner, log};
15
16const META_COLS: [&str; 5] = ["file", "tag", "show", "season", "episode"];
17const EFFECT_COLS: [&str; 11] = [
18 "effect_key",
19 "type",
20 "prompt",
21 "source",
22 "duration_seconds",
23 "loop",
24 "play_duration",
25 "volume_percentage",
26 "ramp_in_seconds",
27 "ramp_out_seconds",
28 "prompt_influence",
29];
30const DEFAULT_COLS: [&str; 10] = [
31 "default_prompt_influence",
32 "default_volume_percentage",
33 "default_ramp_in_seconds",
34 "default_ramp_out_seconds",
35 "default_music_volume_percentage",
36 "default_music_ramp_in_seconds",
37 "default_music_ramp_out_seconds",
38 "default_ambience_volume_percentage",
39 "default_ambience_ramp_in_seconds",
40 "default_ambience_ramp_out_seconds",
41];
42const COPIED_FIELDS: [&str; 10] = [
43 "prompt",
44 "type",
45 "source",
46 "duration_seconds",
47 "loop",
48 "play_duration",
49 "volume_percentage",
50 "ramp_in_seconds",
51 "ramp_out_seconds",
52 "prompt_influence",
53];
54const DEFAULT_PATH: &str = "__configs_default__";
55
56fn all_cols() -> Vec<&'static str> {
57 META_COLS
58 .iter()
59 .chain(EFFECT_COLS.iter())
60 .chain(DEFAULT_COLS.iter())
61 .copied()
62 .collect()
63}
64
65#[derive(Parser)]
66#[command(
67 name = "xil-sfx-csv",
68 about = "Flatten sfx_<tag>.json configs to CSV — one row per effect"
69)]
70struct Args {
71 #[arg(default_value = DEFAULT_PATH)]
73 path: String,
74 #[arg(long, short = 'o', value_name = "FILE")]
76 output: Option<PathBuf>,
77 #[arg(long)]
79 json: bool,
80}
81
82fn tag_from_path(path: &Path, data: &Map<String, Value>) -> Value {
83 if let Some(t) = data.get("tag_override") {
84 return t.clone();
85 }
86 let name = basename(path);
87 let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(&name);
88 Value::String(stem.strip_prefix("sfx_").unwrap_or(stem).to_string())
89}
90
91pub fn flatten(path: &Path) -> Result<Vec<Map<String, Value>>, String> {
94 let text = fs::read_to_string(path).map_err(|e| e.to_string())?;
95 let data: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
96 let obj = data.as_object().cloned().unwrap_or_default();
97 let tag = tag_from_path(path, &obj);
98 let defaults = obj
99 .get("defaults")
100 .and_then(Value::as_object)
101 .cloned()
102 .unwrap_or_default();
103
104 let cols = all_cols();
105 let mut rows = Vec::new();
106 for (key, effect) in obj
107 .get("effects")
108 .and_then(Value::as_object)
109 .cloned()
110 .unwrap_or_default()
111 {
112 let mut row = Map::new();
113 for c in &cols {
114 row.insert((*c).to_string(), Value::String(String::new()));
115 }
116 row.insert("file".into(), Value::String(basename(path)));
117 row.insert("tag".into(), tag.clone());
118 row.insert("show".into(), super::get_or_empty(&obj, "show"));
119 row.insert("season".into(), super::get_or_empty(&obj, "season"));
120 row.insert("episode".into(), super::get_or_empty(&obj, "episode"));
121 for (k, v) in &defaults {
122 row.insert(format!("default_{k}"), v.clone());
123 }
124 row.insert("effect_key".into(), Value::String(key));
125 if let Some(e) = effect.as_object() {
126 for f in COPIED_FIELDS {
127 if let Some(v) = e.get(f) {
128 row.insert(f.to_string(), v.clone());
129 }
130 }
131 }
132 rows.push(row);
133 }
134 Ok(rows)
135}
136
137fn collect_files(path: &Path) -> Vec<PathBuf> {
138 if path.is_file() {
139 vec![abspath(path)]
140 } else if path.is_dir() {
141 glob_children(path, "sfx_", ".json")
142 .into_iter()
143 .map(|p| abspath(&p))
144 .collect()
145 } else {
146 Vec::new()
147 }
148}
149
150fn execute(a: &Args) -> anyhow::Result<()> {
151 let quiet = a.json || a.output.is_none();
152 let resolved = if a.path == DEFAULT_PATH {
153 workspace_root().join("configs")
154 } else {
155 abspath(Path::new(&a.path))
156 };
157
158 let files = collect_files(&resolved);
159 if files.is_empty() {
160 log::error(&format!(
161 "No sfx_*.json files found at: {}",
162 resolved.display()
163 ));
164 return Ok(());
165 }
166 if !quiet {
167 log::info(&format!("Processing {} sfx config file(s)…", files.len()));
168 }
169
170 let mut all_rows = Vec::new();
171 for p in &files {
172 match flatten(p) {
173 Ok(rows) => {
174 if !quiet {
175 log::info(&format!(" {} → {} effect(s)", basename(p), rows.len()));
176 }
177 all_rows.extend(rows);
178 }
179 Err(e) => log::warning(&format!("Skipping {} — {e}", basename(p))),
180 }
181 }
182
183 if all_rows.is_empty() {
184 if !quiet {
185 log::info("No effect rows produced.");
186 }
187 return Ok(());
188 }
189
190 if a.json {
191 let arr = Value::Array(all_rows.into_iter().map(Value::Object).collect());
192 println!("{}", dumps(&arr, Style::INDENT2));
193 return Ok(());
194 }
195
196 let cols = all_cols();
197 if let Some(out) = &a.output {
198 let mut f = fs::File::create(out)?;
199 pycsv::write_dicts(&mut f, &cols, &all_rows)?;
200 log::info(&format!(
201 "Written: {} ({} rows)",
202 out.display(),
203 all_rows.len()
204 ));
205 } else {
206 let mut so = std::io::stdout().lock();
207 pycsv::write_dicts(&mut so, &cols, &all_rows)?;
208 so.flush()?;
209 }
210
211 if !quiet {
212 log::info(&format!(
213 "Total: {} effect rows from {} file(s)",
214 all_rows.len(),
215 files.len()
216 ));
217 }
218 Ok(())
219}
220
221pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
222 log::init("sfx-csv");
223 let a: Args = match super::parse_or_exit("xil-sfx-csv", args) {
224 Ok(a) => a,
225 Err(code) => return Ok(code),
226 };
227 if a.json || a.output.is_none() {
228 execute(&a)?;
229 } else {
230 let _banner = banner::begin(super::prog(), &super::argv_line(args));
231 execute(&a)?;
232 }
233 Ok(0)
234}
235
236pub fn command() -> clap::Command {
238 <Args as clap::CommandFactory>::command()
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn flatten_orders_columns_then_extra_defaults() {
247 let tmp = tempfile::tempdir().unwrap();
248 let p = tmp.path().join("sfx_S01E01.json");
249 fs::write(
250 &p,
251 r#"{"show":"S","season":1,"episode":1,"defaults":{"prompt_influence":0.3,"odd_key":7},
252 "effects":{"BEAT":{"type":"silence","duration_seconds":1.0},"SFX: X":{"prompt":"p","loop":true}}}"#,
253 )
254 .unwrap();
255 let rows = flatten(&p).unwrap();
256 assert_eq!(rows.len(), 2);
257 let keys: Vec<&str> = rows[0].keys().map(String::as_str).collect();
258 assert_eq!(keys[0], "file");
259 assert_eq!(keys[5], "effect_key");
260 assert_eq!(*keys.last().unwrap(), "default_odd_key");
261 assert_eq!(rows[0]["tag"], "S01E01");
262 assert_eq!(rows[0]["effect_key"], "BEAT");
263 assert_eq!(rows[0]["duration_seconds"], 1.0);
264 assert_eq!(rows[0]["prompt"], "");
265 assert_eq!(rows[0]["default_prompt_influence"], 0.3);
266 assert_eq!(rows[1]["loop"], true);
267 }
268
269 #[test]
270 fn tag_override_wins_even_when_null() {
271 let mut d = Map::new();
272 assert_eq!(tag_from_path(Path::new("/c/sfx_S01E01.json"), &d), "S01E01");
273 assert_eq!(tag_from_path(Path::new("/c/other.json"), &d), "other");
274 d.insert("tag_override".into(), Value::Null);
275 assert_eq!(
276 tag_from_path(Path::new("/c/sfx_S01E01.json"), &d),
277 Value::Null
278 );
279 }
280}