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, 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 #[arg(default_value = DEFAULT_PATH)]
42 path: String,
43 #[arg(long, short = 'o', value_name = "FILE")]
45 output: Option<PathBuf>,
46 #[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
64pub 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
203pub 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}