Skip to main content

xil/cmd/
parsed_csv.rs

1//! `xil parsed-csv` — parsed JSON → one row per entry. Port of `XILU012_parsed_csv.py`.
2
3use 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, glob_recursive};
11use xil_core::pycsv;
12use xil_core::pyjson::{dumps, Style};
13use xil_core::workspace::workspace_root;
14use xil_core::{banner, log};
15
16const TRUNCATE: usize = 200;
17const ALL_COLS: [&str; 13] = [
18    "file",
19    "tag",
20    "show",
21    "season",
22    "episode",
23    "seq",
24    "type",
25    "section",
26    "scene",
27    "speaker",
28    "direction",
29    "direction_type",
30    "text",
31];
32const DEFAULT_PATH: &str = "__parsed_default__";
33
34#[derive(Parser)]
35#[command(
36    name = "xil-parsed-csv",
37    about = "Export parsed_<tag>.json entries to CSV — one row per entry"
38)]
39struct Args {
40    /// parsed JSON file, or directory containing parsed_*.json files (default: workspace parsed/)
41    #[arg(default_value = DEFAULT_PATH)]
42    path: String,
43    /// Write CSV to FILE (default: stdout)
44    #[arg(long, short = 'o', value_name = "FILE")]
45    output: Option<PathBuf>,
46    /// Output a JSON array to stdout — no banner, safe to pipe to jq
47    #[arg(long)]
48    json: bool,
49}
50
51fn tag_from_path(path: &Path, data: &Map<String, Value>) -> Value {
52    let name = basename(path);
53    if let Some(mid) = name
54        .strip_prefix("parsed_")
55        .and_then(|s| s.strip_suffix(".json"))
56    {
57        return Value::String(mid.to_string());
58    }
59    data.get("tag_override")
60        .cloned()
61        .unwrap_or(Value::String(name))
62}
63
64/// Rows for one parsed JSON. `Err` carries the message Python's `except
65/// Exception as exc` would show.
66pub fn rows_from_file(path: &Path) -> Result<Vec<Map<String, Value>>, String> {
67    let text = fs::read_to_string(path).map_err(|e| e.to_string())?;
68    let data: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
69    let obj = data.as_object().cloned().unwrap_or_default();
70
71    let mut rows = Vec::new();
72    for entry in obj
73        .get("entries")
74        .and_then(Value::as_array)
75        .cloned()
76        .unwrap_or_default()
77    {
78        let e = entry.as_object().cloned().unwrap_or_default();
79        let seq = e.get("seq").cloned().ok_or_else(|| "'seq'".to_string())?;
80        let kind = e.get("type").cloned().ok_or_else(|| "'type'".to_string())?;
81        let text: String = e
82            .get("text")
83            .and_then(Value::as_str)
84            .unwrap_or("")
85            .chars()
86            .take(TRUNCATE)
87            .collect();
88        let mut row = Map::new();
89        row.insert("file".into(), Value::String(basename(path)));
90        row.insert("tag".into(), tag_from_path(path, &obj));
91        row.insert("show".into(), super::get_or_empty(&obj, "show"));
92        row.insert("season".into(), super::get_or_empty(&obj, "season"));
93        row.insert("episode".into(), super::get_or_empty(&obj, "episode"));
94        row.insert("seq".into(), seq);
95        row.insert("type".into(), kind);
96        for k in ["section", "scene", "speaker", "direction", "direction_type"] {
97            row.insert(k.into(), super::get_or_blank(&e, k));
98        }
99        row.insert("text".into(), Value::String(text));
100        rows.push(row);
101    }
102    Ok(rows)
103}
104
105fn collect_files(path: &Path) -> Vec<PathBuf> {
106    if path.is_file() {
107        vec![abspath(path)]
108    } else if path.is_dir() {
109        glob_children(path, "parsed_", ".json")
110            .into_iter()
111            .map(|p| abspath(&p))
112            .collect()
113    } else {
114        Vec::new()
115    }
116}
117
118fn execute(a: &Args) -> anyhow::Result<()> {
119    let quiet = a.json || a.output.is_none();
120
121    let files = if a.path == DEFAULT_PATH {
122        glob_recursive(&workspace_root().join("parsed"), "parsed_", ".json")
123            .into_iter()
124            .map(|p| abspath(&p))
125            .collect()
126    } else {
127        collect_files(&abspath(Path::new(&a.path)))
128    };
129
130    if files.is_empty() {
131        log::error(&format!("No parsed_*.json files found at: {}", a.path));
132        return Ok(());
133    }
134    if !quiet {
135        log::info(&format!("Processing {} parsed JSON file(s)…", files.len()));
136    }
137
138    let mut all_rows = Vec::new();
139    for p in &files {
140        match rows_from_file(p) {
141            Ok(rows) => {
142                if !quiet {
143                    log::info(&format!("  {} → {} entries", basename(p), rows.len()));
144                }
145                all_rows.extend(rows);
146            }
147            Err(e) => log::warning(&format!("Skipping {} — {e}", basename(p))),
148        }
149    }
150
151    if all_rows.is_empty() {
152        if !quiet {
153            log::info("No rows produced.");
154        }
155        return Ok(());
156    }
157
158    if a.json {
159        let arr = Value::Array(all_rows.into_iter().map(Value::Object).collect());
160        println!("{}", dumps(&arr, Style::INDENT2));
161        return Ok(());
162    }
163
164    if let Some(out) = &a.output {
165        let mut f = fs::File::create(out)?;
166        pycsv::write_dicts(&mut f, &ALL_COLS, &all_rows)?;
167        log::info(&format!(
168            "Written: {}  ({} rows)",
169            out.display(),
170            all_rows.len()
171        ));
172    } else {
173        let mut so = std::io::stdout().lock();
174        pycsv::write_dicts(&mut so, &ALL_COLS, &all_rows)?;
175        so.flush()?;
176    }
177
178    if !quiet {
179        log::info(&format!(
180            "Total: {} entry rows from {} file(s)",
181            all_rows.len(),
182            files.len()
183        ));
184    }
185    Ok(())
186}
187
188pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
189    log::init("parsed-csv");
190    let a: Args = match super::parse_or_exit("xil-parsed-csv", args) {
191        Ok(a) => a,
192        Err(code) => return Ok(code),
193    };
194    if a.json || a.output.is_none() {
195        execute(&a)?;
196    } else {
197        let _banner = banner::begin(super::prog(), &super::argv_line(args));
198        execute(&a)?;
199    }
200    Ok(0)
201}
202
203/// The clap definition behind `--help`, for man pages.
204pub fn command() -> clap::Command {
205    <Args as clap::CommandFactory>::command()
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn rows_flatten_and_truncate() {
214        let tmp = tempfile::tempdir().unwrap();
215        let p = tmp.path().join("parsed_S01E01.json");
216        let long = "x".repeat(250);
217        fs::write(
218            &p,
219            format!(
220                r#"{{"show":"S","season":null,"entries":[
221                    {{"seq":1,"type":"dialogue","section":"act1","scene":null,"speaker":"a","text":"{long}"}},
222                    {{"seq":2,"type":"direction","direction_type":"SFX","text":"boom"}}]}}"#
223            ),
224        )
225        .unwrap();
226        let rows = rows_from_file(&p).unwrap();
227        assert_eq!(rows.len(), 2);
228        assert_eq!(rows[0]["tag"], "S01E01");
229        assert_eq!(rows[0]["season"], Value::Null);
230        assert_eq!(rows[0]["episode"], "");
231        assert_eq!(rows[0]["scene"], "");
232        assert_eq!(rows[0]["text"].as_str().unwrap().len(), 200);
233        assert_eq!(rows[1]["direction_type"], "SFX");
234        assert_eq!(rows[1]["speaker"], "");
235    }
236
237    #[test]
238    fn missing_seq_is_a_keyerror() {
239        let tmp = tempfile::tempdir().unwrap();
240        let p = tmp.path().join("parsed_x.json");
241        fs::write(&p, r#"{"entries":[{"type":"dialogue"}]}"#).unwrap();
242        assert_eq!(rows_from_file(&p).unwrap_err(), "'seq'");
243    }
244
245    #[test]
246    fn tag_falls_back_to_override_then_name() {
247        let mut d = Map::new();
248        assert_eq!(
249            tag_from_path(Path::new("/x/parsed_S02E03.json"), &d),
250            "S02E03"
251        );
252        assert_eq!(tag_from_path(Path::new("/x/other.json"), &d), "other.json");
253        d.insert("tag_override".into(), Value::from("V01C01"));
254        assert_eq!(tag_from_path(Path::new("/x/other.json"), &d), "V01C01");
255    }
256}