Skip to main content

xil/cmd/
csv_join.rs

1//! `xil csv-join` — annotate a parsed episode CSV with SFX and cast config
2//! columns. Port of `XILU003_csv_sfx_join.py`.
3
4use 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    /// Episode tag (e.g. S02E03) — derives default input/output paths
56    #[arg(long, required_unless_present = "tag", conflicts_with = "tag")]
57    episode: Option<String>,
58    /// Raw tag for non-episodic content (e.g. V01C03, D01)
59    #[arg(long)]
60    tag: Option<String>,
61    /// Show name override (default: from project.json)
62    #[arg(long)]
63    show: Option<String>,
64    /// Override input CSV path
65    #[arg(long = "csv", value_name = "CSV_PATH")]
66    csv_path: Option<PathBuf>,
67    /// Override SFX JSON path
68    #[arg(long = "sfx", value_name = "SFX_PATH")]
69    sfx_path: Option<PathBuf>,
70    /// Override cast JSON path
71    #[arg(long = "cast", value_name = "CAST_PATH")]
72    cast_path: Option<PathBuf>,
73    /// Override output CSV path
74    #[arg(long = "output", value_name = "OUT_PATH")]
75    out_path: Option<PathBuf>,
76}
77
78/// One input row as `csv.DictReader` yields it: an absent key is Python's
79/// `None`, which every consumer here treats like an empty string.
80type 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
95/// SFX annotation columns for one row. Only `direction` rows can match.
96fn 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
149/// Cast annotation columns for one row. Only `dialogue` rows carry a speaker.
150fn 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
187/// `(total_rows, direction_rows, sfx_matched, dialogue_rows, cast_matched)`.
188pub 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    // Python parses inside the banner, so a usage error is framed too.
316    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
324/// The clap definition behind `--help`, for man pages.
325pub 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        // A short CSV row has no "text" column at all.
365        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}