1use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use serde_json::{Map, Value};
10use xil_core::pyjson::{dumps, Style};
11use xil_core::workspace::{derive_paths, resolve_slug};
12use xil_core::{banner, log};
13
14#[derive(Parser)]
15#[command(
16 name = "xil-splice",
17 about = "Splice Parsed JSON — insert/delete entries with automatic renumbering"
18)]
19struct Args {
20 #[arg(long, required_unless_present = "tag", conflicts_with = "tag")]
22 episode: Option<String>,
23 #[arg(long)]
25 tag: Option<String>,
26 #[arg(long)]
28 show: Option<String>,
29 #[arg(long)]
31 parsed: Option<PathBuf>,
32 #[arg(long)]
34 insert_after: Option<i64>,
35 #[arg(long)]
37 from_parsed: Option<PathBuf>,
38 #[arg(long)]
40 from_seq_range: Option<String>,
41 #[arg(long)]
43 from_json: Option<PathBuf>,
44 #[arg(long)]
46 section: Option<String>,
47 #[arg(long)]
49 scene: Option<String>,
50 #[arg(long)]
52 delete_seq_range: Option<String>,
53 #[arg(long)]
55 dry_run: bool,
56 #[arg(long)]
58 no_backup: bool,
59 #[arg(long)]
61 quiet: bool,
62}
63
64type Entry = Map<String, Value>;
65
66fn seq_of(e: &Entry) -> i64 {
67 e.get("seq").and_then(Value::as_i64).unwrap_or(0)
68}
69
70fn str_of(e: &Entry, key: &str) -> String {
71 e.get(key).and_then(Value::as_str).unwrap_or("").to_string()
72}
73
74fn py_get_str(e: &Entry, key: &str) -> String {
78 match e.get(key) {
79 None => String::new(),
80 Some(Value::Null) => "None".to_string(),
81 Some(Value::String(s)) => s.clone(),
82 Some(other) => other.to_string(),
83 }
84}
85
86pub fn extract_seq_range(entries: &[Entry], start: i64, end: i64) -> Vec<Entry> {
88 entries
89 .iter()
90 .filter(|e| (start..=end).contains(&seq_of(e)))
91 .cloned()
92 .collect()
93}
94
95pub fn splice_entries(
100 entries: &[Entry],
101 insert_after_seq: i64,
102 new_entries: &[Entry],
103 section_override: Option<&str>,
104 scene_override: Option<&str>,
105) -> Result<Vec<Entry>, String> {
106 if insert_after_seq <= 0 {
107 return Err(format!(
108 "Cannot insert after seq {insert_after_seq} (preamble zone)"
109 ));
110 }
111 let preamble: Vec<Entry> = entries.iter().filter(|e| seq_of(e) <= 0).cloned().collect();
112 let mut body: Vec<Entry> = entries.iter().filter(|e| seq_of(e) > 0).cloned().collect();
113
114 let Some(idx) = body.iter().position(|e| seq_of(e) == insert_after_seq) else {
115 return Err(format!("seq {insert_after_seq} not found in entries"));
116 };
117 let anchor = body[idx].clone();
118
119 let prepared: Vec<Entry> = new_entries
120 .iter()
121 .map(|e| {
122 let mut ne = e.clone();
123 ne.insert(
124 "section".into(),
125 match section_override {
126 Some(s) => Value::String(s.to_string()),
127 None => anchor.get("section").cloned().unwrap_or(Value::Null),
128 },
129 );
130 ne.insert(
131 "scene".into(),
132 match scene_override {
133 Some(s) => Value::String(s.to_string()),
134 None => anchor.get("scene").cloned().unwrap_or(Value::Null),
135 },
136 );
137 ne
138 })
139 .collect();
140
141 body.splice(idx + 1..idx + 1, prepared);
142 for (i, e) in body.iter_mut().enumerate() {
143 e.insert("seq".into(), Value::from(i as i64 + 1));
144 }
145 Ok(preamble.into_iter().chain(body).collect())
146}
147
148pub fn delete_entries(entries: &[Entry], start: i64, end: i64) -> Result<Vec<Entry>, String> {
150 if start <= 0 {
151 return Err(format!(
152 "Cannot delete preamble entries (seq_range starts at {start})"
153 ));
154 }
155 let preamble: Vec<Entry> = entries.iter().filter(|e| seq_of(e) <= 0).cloned().collect();
156 let mut body: Vec<Entry> = entries
157 .iter()
158 .filter(|e| seq_of(e) > 0 && !(start..=end).contains(&seq_of(e)))
159 .cloned()
160 .collect();
161 for (i, e) in body.iter_mut().enumerate() {
162 e.insert("seq".into(), Value::from(i as i64 + 1));
163 }
164 Ok(preamble.into_iter().chain(body).collect())
165}
166
167pub fn update_stats(data: &mut Map<String, Value>) {
169 let entries: Vec<Entry> = data
170 .get("entries")
171 .and_then(Value::as_array)
172 .map(|a| a.iter().filter_map(|e| e.as_object().cloned()).collect())
173 .unwrap_or_default();
174 let body: Vec<&Entry> = entries.iter().filter(|e| seq_of(e) > 0).collect();
175 let dialogue: Vec<&&Entry> = body
176 .iter()
177 .filter(|e| str_of(e, "type") == "dialogue")
178 .collect();
179 let directions = body
180 .iter()
181 .filter(|e| str_of(e, "type") == "direction")
182 .count();
183
184 let mut speakers: Vec<String> = dialogue
185 .iter()
186 .filter_map(|e| {
187 e.get("speaker")
188 .and_then(Value::as_str)
189 .filter(|s| !s.is_empty())
190 .map(str::to_string)
191 })
192 .collect();
193 speakers.sort();
194 speakers.dedup();
195 let mut sections: Vec<String> = body
196 .iter()
197 .filter_map(|e| {
198 e.get("section")
199 .and_then(Value::as_str)
200 .filter(|s| !s.is_empty())
201 .map(str::to_string)
202 })
203 .collect();
204 sections.sort();
205 sections.dedup();
206 let tts_chars: usize = dialogue
207 .iter()
208 .map(|e| str_of(e, "text").chars().count())
209 .sum();
210
211 let mut stats = Map::new();
212 stats.insert("total_entries".into(), Value::from(body.len()));
213 stats.insert("dialogue_lines".into(), Value::from(dialogue.len()));
214 stats.insert("direction_lines".into(), Value::from(directions));
215 stats.insert("characters_for_tts".into(), Value::from(tts_chars));
216 stats.insert(
217 "speakers".into(),
218 Value::Array(speakers.into_iter().map(Value::String).collect()),
219 );
220 stats.insert(
221 "sections".into(),
222 Value::Array(sections.into_iter().map(Value::String).collect()),
223 );
224 data.insert("stats".into(), Value::Object(stats));
225}
226
227fn parse_range(s: &str) -> Option<(i64, i64)> {
229 let (a, b) = s.split_once('-')?;
230 Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
231}
232
233fn head(s: &str, n: usize) -> String {
235 s.chars().take(n).collect()
236}
237
238fn execute(a: &Args) -> anyhow::Result<i32> {
239 let tag = a
240 .episode
241 .clone()
242 .or_else(|| a.tag.clone())
243 .unwrap_or_default();
244 let slug = resolve_slug(a.show.as_deref(), "project.json");
245 let paths = derive_paths(&slug, &tag);
246 let target = a.parsed.clone().unwrap_or_else(|| paths["parsed"].clone());
247
248 if !target.exists() {
249 log::error(&format!(
250 "Target parsed JSON not found: {}",
251 target.display()
252 ));
253 return Ok(0);
254 }
255 log::info(&format!(" Target: {}", target.display()));
256
257 let mut new_entries: Option<Vec<Entry>> = None;
259 if a.insert_after.is_some() {
260 if let (Some(from), Some(range)) = (&a.from_parsed, &a.from_seq_range) {
261 let Some((start, end)) = parse_range(range) else {
262 log::error(&format!("Expected N-M range, got: {range}"));
263 return Ok(2);
264 };
265 let source: Value = serde_json::from_str(&fs::read_to_string(from)?)?;
266 let source_entries: Vec<Entry> = source
267 .get("entries")
268 .and_then(Value::as_array)
269 .map(|v| v.iter().filter_map(|e| e.as_object().cloned()).collect())
270 .unwrap_or_default();
271 let picked = extract_seq_range(&source_entries, start, end);
272 if picked.is_empty() {
273 log::warning(&format!(
274 "No entries found in seq range {start}–{end} of {}",
275 from.display()
276 ));
277 return Ok(0);
278 }
279 log::info(&format!(
280 " Source: {} seq {start}–{end} ({} entries)",
281 from.display(),
282 picked.len()
283 ));
284 new_entries = Some(picked);
285 } else if let Some(from_json) = &a.from_json {
286 let v: Value = serde_json::from_str(&fs::read_to_string(from_json)?)?;
287 let picked: Vec<Entry> = v
288 .as_array()
289 .map(|a| a.iter().filter_map(|e| e.as_object().cloned()).collect())
290 .unwrap_or_default();
291 log::info(&format!(
292 " Source: {} ({} entries)",
293 from_json.display(),
294 picked.len()
295 ));
296 new_entries = Some(picked);
297 } else {
298 log::error("--insert-after requires --from-parsed + --from-seq-range or --from-json");
299 return Ok(0);
300 }
301 }
302
303 let delete_range = match &a.delete_seq_range {
304 Some(r) => match parse_range(r) {
305 Some(v) => Some(v),
306 None => {
307 log::error(&format!("Expected N-M range, got: {r}"));
308 return Ok(2);
309 }
310 },
311 None => None,
312 };
313
314 if new_entries.is_none() && delete_range.is_none() {
315 log::error("Nothing to do — specify --insert-after or --delete-seq-range");
316 return Ok(0);
317 }
318
319 let backup_path = (!a.no_backup && !a.dry_run).then(|| {
320 let name = format!("pre_splice_parsed_{slug}_{tag}.json");
321 match target.parent().filter(|d| !d.as_os_str().is_empty()) {
322 Some(d) => d.join(name),
323 None => PathBuf::from(name),
324 }
325 });
326
327 run_splice(
328 &target,
329 a.insert_after,
330 new_entries.as_deref(),
331 delete_range,
332 a.section.as_deref(),
333 a.scene.as_deref(),
334 a.dry_run,
335 backup_path.as_deref(),
336 a.quiet,
337 )?;
338
339 if !a.dry_run {
340 log::info("\n Next steps:");
341 log::info(&format!(
342 " 1. python XILP007_stem_migrator.py --episode {tag} --orig-prefix pre_splice_"
343 ));
344 log::info(&format!(
345 " 2. python XILP002_producer.py --episode {tag}"
346 ));
347 }
348 Ok(0)
349}
350
351#[allow(clippy::too_many_arguments)]
352fn run_splice(
353 target: &Path,
354 insert_after: Option<i64>,
355 new_entries: Option<&[Entry]>,
356 delete_range: Option<(i64, i64)>,
357 section_override: Option<&str>,
358 scene_override: Option<&str>,
359 dry_run: bool,
360 backup_path: Option<&Path>,
361 quiet: bool,
362) -> anyhow::Result<()> {
363 let original_content = fs::read_to_string(target)?;
364 let mut data: Map<String, Value> = serde_json::from_str(&original_content)?;
365 let mut entries: Vec<Entry> = data
366 .get("entries")
367 .and_then(Value::as_array)
368 .map(|a| a.iter().filter_map(|e| e.as_object().cloned()).collect())
369 .unwrap_or_default();
370 let original_count = entries.iter().filter(|e| seq_of(e) > 0).count();
371
372 if let Some((start, end)) = delete_range {
373 let deleted: Vec<Entry> = entries
374 .iter()
375 .filter(|e| (start..=end).contains(&seq_of(e)))
376 .cloned()
377 .collect();
378 entries = delete_entries(&entries, start, end).map_err(|e| anyhow::anyhow!(e))?;
379 if !quiet {
380 log::info(&format!(
381 "\n DELETE seq {start}–{end}: {} entries removed",
382 deleted.len()
383 ));
384 for e in &deleted {
385 log::info(&format!(
386 " - seq {} [{}] {} — {}",
387 seq_of(e),
388 str_of(e, "type"),
389 py_get_str(e, "speaker"),
390 head(&str_of(e, "text"), 60)
391 ));
392 }
393 }
394 }
395
396 if let (Some(after), Some(new)) = (insert_after, new_entries) {
397 if !new.is_empty() {
398 if !quiet {
399 match entries.iter().find(|e| seq_of(e) == after) {
402 Some(anchor) => log::info(&format!(
403 "\n INSERT {} entries after seq {after} ({}...)",
404 new.len(),
405 head(&str_of(anchor, "text"), 40)
406 )),
407 None => log::info(""),
408 }
409 for e in new {
410 let label = section_override.unwrap_or("(inherit)");
411 log::info(&format!(
412 " + [{}] {} — {} [section={label}]",
413 str_of(e, "type"),
414 py_get_str(e, "speaker"),
415 head(&str_of(e, "text"), 60)
416 ));
417 }
418 }
419 entries = splice_entries(&entries, after, new, section_override, scene_override)
420 .map_err(|e| anyhow::anyhow!(e))?;
421 }
422 }
423
424 data.insert(
425 "entries".into(),
426 Value::Array(entries.iter().cloned().map(Value::Object).collect()),
427 );
428 update_stats(&mut data);
429
430 let new_count = entries.iter().filter(|e| seq_of(e) > 0).count();
431 log::info(&format!(
432 "\n Summary: {original_count} → {new_count} entries (body)"
433 ));
434
435 if dry_run {
436 log::info(" [DRY RUN] No files written.");
437 return Ok(());
438 }
439 if let Some(b) = backup_path {
440 fs::write(b, &original_content)?;
441 log::info(&format!(" Backup written: {}", b.display()));
442 }
443 fs::write(
444 target,
445 format!("{}\n", dumps(&Value::Object(data), Style::INDENT2_UTF8)),
446 )?;
447 log::info(&format!(" Updated: {}", target.display()));
448 Ok(())
449}
450
451pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
452 log::init("splice");
453 let _banner = banner::begin(super::prog(), &super::argv_line(args));
454 let a: Args = match super::parse_or_exit("xil-splice", args) {
455 Ok(a) => a,
456 Err(code) => return Ok(code),
457 };
458 execute(&a)
459}
460
461pub fn command() -> clap::Command {
463 <Args as clap::CommandFactory>::command()
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use serde_json::json;
470
471 fn entries(v: Value) -> Vec<Entry> {
472 v.as_array()
473 .unwrap()
474 .iter()
475 .map(|e| e.as_object().unwrap().clone())
476 .collect()
477 }
478
479 fn seqs(es: &[Entry]) -> Vec<i64> {
480 es.iter().map(seq_of).collect()
481 }
482
483 #[test]
484 fn insert_renumbers_the_body_and_spares_the_preamble() {
485 let base = entries(json!([
486 {"seq": -1, "type": "dialogue", "text": "pre", "section": "preamble", "scene": null},
487 {"seq": 1, "type": "dialogue", "text": "a", "section": "act1", "scene": "scene-1"},
488 {"seq": 2, "type": "dialogue", "text": "b", "section": "act1", "scene": "scene-1"}
489 ]));
490 let new = entries(
491 json!([{"seq": 99, "type": "dialogue", "text": "n", "section": "x", "scene": "y"}]),
492 );
493 let out = splice_entries(&base, 1, &new, None, None).unwrap();
494 assert_eq!(seqs(&out), vec![-1, 1, 2, 3]);
495 assert_eq!(str_of(&out[2], "text"), "n");
496 assert_eq!(
497 str_of(&out[2], "section"),
498 "act1",
499 "inherited from the anchor"
500 );
501 assert_eq!(str_of(&out[2], "scene"), "scene-1");
502 }
503
504 #[test]
505 fn overrides_win_over_inheritance() {
506 let base = entries(
507 json!([{"seq": 1, "type": "dialogue", "text": "a", "section": "act1", "scene": "scene-1"}]),
508 );
509 let new = entries(json!([{"seq": 9, "type": "dialogue", "text": "n"}]));
510 let out = splice_entries(&base, 1, &new, Some("post-interview"), Some("scene-9")).unwrap();
511 assert_eq!(str_of(&out[1], "section"), "post-interview");
512 assert_eq!(str_of(&out[1], "scene"), "scene-9");
513 }
514
515 #[test]
516 fn insert_rejects_the_preamble_zone_and_missing_anchors() {
517 let base = entries(
518 json!([{"seq": 1, "type": "dialogue", "text": "a", "section": null, "scene": null}]),
519 );
520 assert_eq!(
521 splice_entries(&base, 0, &[], None, None).unwrap_err(),
522 "Cannot insert after seq 0 (preamble zone)"
523 );
524 assert_eq!(
525 splice_entries(&base, 7, &[], None, None).unwrap_err(),
526 "seq 7 not found in entries"
527 );
528 }
529
530 #[test]
531 fn delete_closes_the_gap() {
532 let base = entries(json!([
533 {"seq": -1, "type": "dialogue", "text": "pre"},
534 {"seq": 1, "type": "dialogue", "text": "a"},
535 {"seq": 2, "type": "dialogue", "text": "b"},
536 {"seq": 3, "type": "dialogue", "text": "c"},
537 {"seq": 4, "type": "dialogue", "text": "d"}
538 ]));
539 let out = delete_entries(&base, 2, 3).unwrap();
540 assert_eq!(seqs(&out), vec![-1, 1, 2]);
541 assert_eq!(str_of(&out[2], "text"), "d");
542 assert_eq!(
543 delete_entries(&base, 0, 1).unwrap_err(),
544 "Cannot delete preamble entries (seq_range starts at 0)"
545 );
546 }
547
548 #[test]
549 fn stats_count_body_entries_only() {
550 let mut data: Map<String, Value> = json!({"entries": [
551 {"seq": -1, "type": "dialogue", "text": "pre", "speaker": "tina", "section": "preamble"},
552 {"seq": 1, "type": "dialogue", "text": "Café", "speaker": "adam", "section": "act1"},
553 {"seq": 2, "type": "direction", "text": "SFX: X", "section": "act1"},
554 {"seq": 3, "type": "dialogue", "text": "hi", "speaker": "adam", "section": "act2"}
555 ]})
556 .as_object()
557 .unwrap()
558 .clone();
559 update_stats(&mut data);
560 let s = &data["stats"];
561 assert_eq!(s["total_entries"], 3, "the preamble entry is excluded");
562 assert_eq!(s["dialogue_lines"], 2);
563 assert_eq!(s["direction_lines"], 1);
564 assert_eq!(s["characters_for_tts"], 6, "4 + 2, counted in characters");
565 assert_eq!(s["speakers"], json!(["adam"]));
566 assert_eq!(s["sections"], json!(["act1", "act2"]));
567 }
568
569 #[test]
570 fn absent_and_null_speakers_render_differently() {
571 let e = entries(
572 json!([{"seq": 1, "type": "direction", "speaker": null, "text": "t"},
573 {"seq": 2, "type": "direction", "text": "t"}]),
574 );
575 assert_eq!(
576 py_get_str(&e[0], "speaker"),
577 "None",
578 "present but null prints None"
579 );
580 assert_eq!(py_get_str(&e[1], "speaker"), "", "absent takes the default");
581 }
582
583 #[test]
584 fn range_parsing() {
585 assert_eq!(parse_range("232-233"), Some((232, 233)));
586 assert_eq!(parse_range("5-5"), Some((5, 5)));
587 assert_eq!(parse_range("nope"), None);
588 assert_eq!(parse_range("1-"), None);
589 }
590}