1use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use indexmap::IndexMap;
10use serde_json::{Map, Value};
11use xil_core::fsutil::abspath;
12use xil_core::pycsv;
13use xil_core::sfxlib::slugify_effect_key;
14use xil_core::workspace::{derive_paths, resolve_slug};
15use xil_core::{banner, log};
16
17const INPUT_COLS: [&str; 10] = [
18 "md_line_num",
19 "md_raw",
20 "seq",
21 "type",
22 "section",
23 "scene",
24 "speaker",
25 "direction",
26 "text",
27 "direction_type",
28];
29
30const SFX_COLS: [&str; 7] = [
31 "sfx_type",
32 "sfx_prompt",
33 "sfx_duration_seconds",
34 "sfx_prompt_influence",
35 "sfx_loop",
36 "sfx_slug",
37 "sfx_matched",
38];
39
40const CAST_COLS: [&str; 6] = [
41 "cast_full_name",
42 "cast_voice_id",
43 "cast_pan",
44 "cast_filter",
45 "cast_role",
46 "cast_matched",
47];
48
49#[derive(Parser)]
50#[command(
51 name = "xil-csv-join",
52 about = "Annotate a parsed episode CSV with SFX and cast config data."
53)]
54struct Args {
55 #[arg(long, required_unless_present = "tag", conflicts_with = "tag")]
57 episode: Option<String>,
58 #[arg(long)]
60 tag: Option<String>,
61 #[arg(long)]
63 show: Option<String>,
64 #[arg(long = "csv", value_name = "CSV_PATH")]
66 csv_path: Option<PathBuf>,
67 #[arg(long = "sfx", value_name = "SFX_PATH")]
69 sfx_path: Option<PathBuf>,
70 #[arg(long = "cast", value_name = "CAST_PATH")]
72 cast_path: Option<PathBuf>,
73 #[arg(long = "output", value_name = "OUT_PATH")]
75 out_path: Option<PathBuf>,
76}
77
78type Row = IndexMap<String, String>;
81
82fn row_get<'a>(row: &'a Row, key: &str) -> &'a str {
83 row.get(key).map(String::as_str).unwrap_or("")
84}
85
86fn blank(cols: &[&str], matched_col: &str) -> Map<String, Value> {
87 let mut m = Map::new();
88 for c in cols {
89 m.insert((*c).into(), Value::String(String::new()));
90 }
91 m.insert(matched_col.into(), Value::String("FALSE".into()));
92 m
93}
94
95fn join_sfx(
97 row: &Row,
98 effects: &Map<String, Value>,
99 default_influence: &Value,
100) -> Map<String, Value> {
101 if row_get(row, "type") != "direction" {
102 return blank(&SFX_COLS, "sfx_matched");
103 }
104 let text = row_get(row, "text");
105 let Some(entry) = effects.get(text).and_then(Value::as_object) else {
106 return blank(&SFX_COLS, "sfx_matched");
107 };
108 let influence = match entry.get("prompt_influence") {
109 None | Some(Value::Null) => default_influence.clone(),
110 Some(v) => v.clone(),
111 };
112 let mut m = Map::new();
113 m.insert(
114 "sfx_type".into(),
115 entry
116 .get("type")
117 .cloned()
118 .unwrap_or(Value::String("sfx".into())),
119 );
120 m.insert("sfx_prompt".into(), super::get_or_blank(entry, "prompt"));
121 m.insert(
122 "sfx_duration_seconds".into(),
123 super::get_or_empty(entry, "duration_seconds"),
124 );
125 m.insert(
126 "sfx_prompt_influence".into(),
127 if influence.is_null() {
128 Value::String(String::new())
129 } else {
130 influence
131 },
132 );
133 m.insert(
134 "sfx_loop".into(),
135 Value::String(
136 if entry.get("loop").is_some_and(super::truthy) {
137 "TRUE"
138 } else {
139 ""
140 }
141 .into(),
142 ),
143 );
144 m.insert("sfx_slug".into(), Value::String(slugify_effect_key(text)));
145 m.insert("sfx_matched".into(), Value::String("TRUE".into()));
146 m
147}
148
149fn join_cast(row: &Row, cast: &Map<String, Value>) -> Map<String, Value> {
151 if row_get(row, "type") != "dialogue" {
152 return blank(&CAST_COLS, "cast_matched");
153 }
154 let speaker = row_get(row, "speaker");
155 if speaker.is_empty() {
156 return blank(&CAST_COLS, "cast_matched");
157 }
158 let Some(member) = cast.get(speaker).and_then(Value::as_object) else {
159 return blank(&CAST_COLS, "cast_matched");
160 };
161 let mut m = Map::new();
162 m.insert(
163 "cast_full_name".into(),
164 super::get_or_empty(member, "full_name"),
165 );
166 m.insert(
167 "cast_voice_id".into(),
168 super::get_or_empty(member, "voice_id"),
169 );
170 m.insert("cast_pan".into(), super::get_or_empty(member, "pan"));
171 m.insert(
172 "cast_filter".into(),
173 Value::String(
174 if member.get("filter").is_some_and(super::truthy) {
175 "TRUE"
176 } else {
177 "FALSE"
178 }
179 .into(),
180 ),
181 );
182 m.insert("cast_role".into(), super::get_or_empty(member, "role"));
183 m.insert("cast_matched".into(), Value::String("TRUE".into()));
184 m
185}
186
187pub fn annotate_csv(
189 csv_path: &Path,
190 sfx_path: &Path,
191 cast_path: &Path,
192 out_path: &Path,
193) -> anyhow::Result<(usize, usize, usize, usize, usize)> {
194 let sfx_cfg: Value = serde_json::from_str(&fs::read_to_string(sfx_path)?)?;
195 let effects = sfx_cfg
196 .get("effects")
197 .and_then(Value::as_object)
198 .cloned()
199 .unwrap_or_default();
200 let default_influence = sfx_cfg
201 .get("defaults")
202 .and_then(|d| d.get("prompt_influence"))
203 .cloned()
204 .unwrap_or(Value::Null);
205
206 let cast_cfg: Value = serde_json::from_str(&fs::read_to_string(cast_path)?)?;
207 let cast = cast_cfg
208 .get("cast")
209 .and_then(Value::as_object)
210 .cloned()
211 .unwrap_or_default();
212
213 let rows = pycsv::read_dicts(&fs::read_to_string(csv_path)?);
214
215 let direction_rows = rows
216 .iter()
217 .filter(|r| row_get(r, "type") == "direction")
218 .count();
219 let dialogue_rows = rows
220 .iter()
221 .filter(|r| row_get(r, "type") == "dialogue")
222 .count();
223 let sfx_matched = rows
224 .iter()
225 .filter(|r| row_get(r, "type") == "direction" && effects.contains_key(row_get(r, "text")))
226 .count();
227 let cast_matched = rows
228 .iter()
229 .filter(|r| row_get(r, "type") == "dialogue" && cast.contains_key(row_get(r, "speaker")))
230 .count();
231
232 let cols: Vec<&str> = INPUT_COLS
233 .iter()
234 .chain(SFX_COLS.iter())
235 .chain(CAST_COLS.iter())
236 .copied()
237 .collect();
238 let out_rows: Vec<Map<String, Value>> = rows
239 .iter()
240 .map(|row| {
241 let mut m = Map::new();
242 for c in INPUT_COLS {
243 m.insert(c.into(), Value::String(row_get(row, c).to_string()));
244 }
245 m.extend(join_sfx(row, &effects, &default_influence));
246 m.extend(join_cast(row, &cast));
247 m
248 })
249 .collect();
250 let mut f = fs::File::create(out_path)?;
251 pycsv::write_dicts(&mut f, &cols, &out_rows)?;
252
253 Ok((
254 rows.len(),
255 direction_rows,
256 sfx_matched,
257 dialogue_rows,
258 cast_matched,
259 ))
260}
261
262fn execute(a: &Args) -> anyhow::Result<i32> {
263 let tag = a
264 .episode
265 .clone()
266 .or_else(|| a.tag.clone())
267 .unwrap_or_default();
268 let slug = resolve_slug(a.show.as_deref(), "project.json");
269 let p = derive_paths(&slug, &tag);
270 let csv_path = a
271 .csv_path
272 .clone()
273 .unwrap_or_else(|| p["parsed_csv"].clone());
274 let sfx_path = a.sfx_path.clone().unwrap_or_else(|| p["sfx"].clone());
275 let cast_path = a.cast_path.clone().unwrap_or_else(|| p["cast"].clone());
276 let out_path = a
277 .out_path
278 .clone()
279 .unwrap_or_else(|| p["annotated_csv"].clone());
280
281 if abspath(&out_path) == abspath(&csv_path) {
282 log::error(&format!(
283 "output path '{}' is the same as input '{}'",
284 out_path.display(),
285 csv_path.display()
286 ));
287 return Ok(1);
288 }
289 for (path, label) in [
290 (&csv_path, "CSV"),
291 (&sfx_path, "SFX JSON"),
292 (&cast_path, "Cast JSON"),
293 ] {
294 if !path.exists() {
295 log::error(&format!("{label} file not found: {}", path.display()));
296 return Ok(1);
297 }
298 }
299
300 let (total, n_dir, sfx_hit, n_dlg, cast_hit) =
301 annotate_csv(&csv_path, &sfx_path, &cast_path, &out_path)?;
302 log::info(&format!("Rows written: {total}"));
303 log::info(&format!(
304 " SFX matched: {sfx_hit} / {n_dir} direction rows"
305 ));
306 log::info(&format!(
307 " Cast matched: {cast_hit} / {n_dlg} dialogue rows"
308 ));
309 log::info(&format!("Output: {}", out_path.display()));
310 Ok(0)
311}
312
313pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
314 log::init("csv-join");
315 let _banner = banner::begin(super::prog(), &super::argv_line(args));
317 let a: Args = match super::parse_or_exit("xil-csv-join", args) {
318 Ok(a) => a,
319 Err(code) => return Ok(code),
320 };
321 execute(&a)
322}
323
324pub fn command() -> clap::Command {
326 <Args as clap::CommandFactory>::command()
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use serde_json::json;
333
334 fn row(pairs: &[(&str, &str)]) -> Row {
335 pairs
336 .iter()
337 .map(|(k, v)| (k.to_string(), v.to_string()))
338 .collect()
339 }
340
341 #[test]
342 fn direction_rows_join_effects_and_inherit_default_influence() {
343 let effects = json!({"SFX: DOOR": {"prompt": null, "duration_seconds": 5.0, "loop": true}});
344 let m = join_sfx(
345 &row(&[("type", "direction"), ("text", "SFX: DOOR")]),
346 effects.as_object().unwrap(),
347 &json!(0.3),
348 );
349 assert_eq!(m["sfx_type"], "sfx");
350 assert_eq!(m["sfx_prompt"], "");
351 assert_eq!(pycsv::cell(&m["sfx_duration_seconds"]), "5.0");
352 assert_eq!(pycsv::cell(&m["sfx_prompt_influence"]), "0.3");
353 assert_eq!(m["sfx_loop"], "TRUE");
354 assert_eq!(m["sfx_slug"], "sfx_door");
355 assert_eq!(m["sfx_matched"], "TRUE");
356 }
357
358 #[test]
359 fn other_rows_and_misses_are_blank() {
360 let effects = json!({}).as_object().unwrap().clone();
361 let m = join_sfx(&row(&[("type", "dialogue")]), &effects, &Value::Null);
362 assert_eq!(m["sfx_matched"], "FALSE");
363 assert_eq!(m["sfx_type"], "");
364 let m = join_sfx(&row(&[("type", "direction")]), &effects, &Value::Null);
366 assert_eq!(m["sfx_matched"], "FALSE");
367 }
368
369 #[test]
370 fn cast_filter_is_truthiness_not_presence() {
371 let cast = json!({"host": {"full_name": "Ada", "filter": "", "pan": -0.5},
372 "guest": {"filter": "phone"}});
373 let cast = cast.as_object().unwrap();
374 let m = join_cast(&row(&[("type", "dialogue"), ("speaker", "host")]), cast);
375 assert_eq!(m["cast_filter"], "FALSE");
376 assert_eq!(pycsv::cell(&m["cast_pan"]), "-0.5");
377 assert_eq!(m["cast_role"], "");
378 let m = join_cast(&row(&[("type", "dialogue"), ("speaker", "guest")]), cast);
379 assert_eq!(m["cast_filter"], "TRUE");
380 let m = join_cast(&row(&[("type", "dialogue"), ("speaker", "")]), cast);
381 assert_eq!(m["cast_matched"], "FALSE");
382 }
383}