1pub mod hints;
5pub mod sections;
6pub mod speakers;
7pub mod text;
8
9use std::path::Path;
10use std::sync::LazyLock;
11
12use indexmap::IndexMap;
13use regex::Regex;
14use serde_json::{Map, Value};
15use unicode_normalization::UnicodeNormalization;
16
17use crate::pyjson::float_repr;
18use crate::workspace::{episode_tag, show_slug};
19use hints::{parse_direction_hint, Hints};
20use sections::{get_section_map, match_section};
21use speakers::{extract_cast_from_script, load_speakers, try_match_speaker, CastEntry, Speakers};
22use text::*;
23
24static EPISODE_N: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"Episode\s+(\d+)").unwrap());
25static SEASON_OR_EPISODE: LazyLock<Regex> =
26 LazyLock::new(|| Regex::new(r"\b(?:Season\s+\d+|Episode\s+\d+)").unwrap());
27static SEASON_N: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"Season\s+(\d+)").unwrap());
28static EP_REST: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"Episode\s+\d+[:\s]+(.*)").unwrap());
29static QUOTED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#""([^"]+)""#).unwrap());
30static ARC: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\bArc:\s*"([^"]+)""#).unwrap());
31
32#[derive(Clone, Debug)]
35pub struct Entry {
36 pub seq: i64,
37 pub kind: &'static str,
38 pub section: Option<String>,
39 pub scene: Option<String>,
40 pub speaker: Option<String>,
41 pub direction: Option<String>,
42 pub text: String,
43 pub direction_type: Option<&'static str>,
44 pub sfx_source: Option<String>,
45 pub sfx_overrides: IndexMap<String, f64>,
46}
47
48impl Entry {
49 pub fn to_json(&self) -> Value {
51 let mut m = Map::new();
52 m.insert("seq".into(), Value::from(self.seq));
53 m.insert("type".into(), Value::from(self.kind));
54 m.insert("section".into(), opt_str(&self.section));
55 m.insert("scene".into(), opt_str(&self.scene));
56 m.insert("speaker".into(), opt_str(&self.speaker));
57 m.insert("direction".into(), opt_str(&self.direction));
58 m.insert("text".into(), Value::String(self.text.clone()));
59 m.insert(
60 "direction_type".into(),
61 self.direction_type.map(Value::from).unwrap_or(Value::Null),
62 );
63 m.insert("sfx_source".into(), opt_str(&self.sfx_source));
64 m.insert(
65 "sfx_overrides".into(),
66 if self.sfx_overrides.is_empty() {
67 Value::Null
68 } else {
69 Value::Object(
70 self.sfx_overrides
71 .iter()
72 .map(|(k, v)| (k.clone(), json_f64(*v)))
73 .collect(),
74 )
75 },
76 );
77 Value::Object(m)
78 }
79}
80
81fn opt_str(v: &Option<String>) -> Value {
82 v.clone().map(Value::String).unwrap_or(Value::Null)
83}
84
85pub fn nfc_normalize(text: &str) -> String {
89 text.nfc().collect()
90}
91
92pub fn stats_from_entries_json(entries: &[Value]) -> Map<String, Value> {
98 let is_kind = |e: &&Value, kind: &str| e.get("type").and_then(Value::as_str) == Some(kind);
99
100 let dialogue: Vec<&Value> = entries.iter().filter(|e| is_kind(e, "dialogue")).collect();
101 let tts_chars: usize = dialogue
102 .iter()
103 .filter_map(|e| e.get("text").and_then(Value::as_str))
104 .map(|t| t.chars().count())
105 .sum();
106
107 let mut speakers: Vec<String> = dialogue
108 .iter()
109 .filter_map(|e| e.get("speaker").and_then(Value::as_str))
110 .map(str::to_string)
111 .collect();
112 speakers.sort();
113 speakers.dedup();
114
115 let mut sections: Vec<String> = entries
116 .iter()
117 .filter_map(|e| e.get("section").and_then(Value::as_str))
118 .filter(|s| !s.is_empty())
119 .map(str::to_string)
120 .collect();
121 sections.sort();
122 sections.dedup();
123
124 let mut stats = Map::new();
125 stats.insert("total_entries".into(), Value::from(entries.len()));
126 stats.insert("dialogue_lines".into(), Value::from(dialogue.len()));
127 stats.insert(
128 "direction_lines".into(),
129 Value::from(entries.iter().filter(|e| is_kind(e, "direction")).count()),
130 );
131 stats.insert("characters_for_tts".into(), Value::from(tts_chars));
132 stats.insert(
133 "speakers".into(),
134 Value::Array(speakers.into_iter().map(Value::String).collect()),
135 );
136 stats.insert(
137 "sections".into(),
138 Value::Array(sections.into_iter().map(Value::String).collect()),
139 );
140 stats
141}
142
143fn json_f64(v: f64) -> Value {
145 serde_json::Number::from_f64(v)
146 .map(Value::Number)
147 .unwrap_or(Value::Null)
148}
149
150#[derive(Debug, PartialEq)]
152pub struct Header {
153 pub show: String,
154 pub season: Option<i64>,
155 pub episode: i64,
156 pub title: String,
157 pub season_title: Option<String>,
158}
159
160pub fn parse_script_header(line: &str) -> Option<Header> {
163 let ep = EPISODE_N.captures(line)?;
164 let show = match SEASON_OR_EPISODE.find(line) {
165 Some(m) => line[..m.start()].trim().to_string(),
166 None => "Unknown Show".to_string(),
167 };
168 let season = SEASON_N.captures(line).and_then(|c| c[1].parse().ok());
169 let episode: i64 = ep[1].parse().unwrap_or(1);
170 let title = match EP_REST.captures(line) {
171 Some(c) => {
172 let rest = c[1].to_string();
173 match QUOTED.captures(&rest) {
174 Some(q) => q[1].to_string(),
175 None => rest.trim().to_string(),
176 }
177 }
178 None => String::new(),
179 };
180 let season_title = ARC.captures(line).map(|c| c[1].to_string());
181 Some(Header {
182 show,
183 season,
184 episode,
185 title,
186 season_title,
187 })
188}
189
190pub struct Parsed {
192 pub show: String,
193 pub season: Option<i64>,
194 pub episode: i64,
195 pub title: String,
196 pub season_title: Option<String>,
197 pub source_file: String,
198 pub entries: Vec<Entry>,
199 pub debug_line_map: Vec<(usize, String, usize)>,
201}
202
203impl Parsed {
204 pub fn tag(&self) -> String {
205 episode_tag(self.season, self.episode)
206 }
207
208 pub fn to_json(&self) -> Value {
210 let entries_json: Vec<Value> = self.entries.iter().map(Entry::to_json).collect();
211 let stats = stats_from_entries_json(&entries_json);
212
213 let mut m = Map::new();
214 m.insert("show".into(), Value::String(self.show.clone()));
215 m.insert(
216 "season".into(),
217 self.season.map(Value::from).unwrap_or(Value::Null),
218 );
219 m.insert("episode".into(), Value::from(self.episode));
220 m.insert("title".into(), Value::String(self.title.clone()));
221 m.insert("season_title".into(), opt_str(&self.season_title));
222 m.insert(
223 "source_file".into(),
224 Value::String(self.source_file.clone()),
225 );
226 m.insert("entries".into(), Value::Array(entries_json));
227 m.insert("stats".into(), Value::Object(stats));
228 Value::Object(m)
229 }
230}
231
232pub struct ParseOpts<'a> {
235 pub project_type: &'a str,
236 pub speakers_path: Option<&'a Path>,
237 pub season_fallback: Option<i64>,
239 pub season_title_fallback: Option<String>,
241}
242
243pub fn parse_script(
247 raw: &str,
248 source_file: &str,
249 opts: &ParseOpts,
250 warn: &mut dyn FnMut(String),
251) -> Parsed {
252 let section_map = get_section_map(opts.project_type);
253
254 let raw: String = raw.nfc().collect();
258 let raw = strip_markdown_escapes(&raw);
259 let raw = strip_markdown_formatting(&raw);
260 let lines: Vec<String> = raw.split('\n').map(str::to_string).collect();
261
262 let mut entries: Vec<Entry> = Vec::new();
263 let mut debug_line_map: Vec<(usize, String, usize)> = Vec::new();
264 let mut seq: i64 = 0;
265 let mut current_section: Option<String> = None;
266 let mut current_scene: Option<String> = None;
267 let mut in_metadata = false;
268 let mut last_dialogue_idx: Option<usize> = None;
269 let mut pending_speaker: Option<(String, Option<String>)> = None;
270
271 let mut hdr_idx = 0;
274 while hdr_idx < lines.len() && lines[hdr_idx].trim().is_empty() {
275 hdr_idx += 1;
276 }
277 let first_line = lines.get(hdr_idx).map(|l| l.trim()).unwrap_or("");
278 let header = if first_line.is_empty() {
279 None
280 } else {
281 parse_script_header(first_line)
282 };
283 let mut start = 0;
284 let (show, mut season, episode, title, mut season_title) = match header {
285 Some(h) => {
286 start = hdr_idx + 1;
287 (h.show, h.season, h.episode, h.title, h.season_title)
288 }
289 None => ("Unknown Show".to_string(), None, 1, String::new(), None),
290 };
291
292 let script_slug = show_slug(&show);
293 if season.is_none() {
294 season = opts.season_fallback;
295 }
296 if season_title.is_none() {
297 season_title = opts.season_title_fallback.clone();
298 }
299
300 let cast_entries: Vec<CastEntry> = extract_cast_from_script(&lines[start.min(lines.len())..]);
302 let mut in_cast = false;
303 for (i, raw) in lines.iter().enumerate().skip(start) {
304 let line = raw.trim();
305 if line == "CAST:" {
306 in_cast = true;
307 continue;
308 }
309 if in_cast && (line == "===" || (!line.is_empty() && !line.starts_with('*'))) {
310 start = i;
311 break;
312 }
313 }
314
315 let speakers = load_speakers(opts.speakers_path, &cast_entries);
316
317 for (i, raw_line) in lines.iter().enumerate().skip(start) {
318 let line = raw_line.trim().to_string();
319 if line.is_empty() {
320 continue;
321 }
322
323 if let Some((p_key, p_direction)) = pending_speaker.clone() {
325 if line.starts_with('(') && line.ends_with(')') {
326 let d = line[1..line.len() - 1].trim().to_string();
327 pending_speaker = Some((p_key, Some(d)));
328 continue;
329 }
330 if is_stage_direction(&line) {
333 emit_directions(
334 &line,
335 i,
336 raw_line,
337 &script_slug,
338 ¤t_section,
339 ¤t_scene,
340 &mut seq,
341 &mut entries,
342 &mut debug_line_map,
343 warn,
344 );
345 continue;
346 }
347 seq += 1;
348 entries.push(Entry {
349 seq,
350 kind: "dialogue",
351 section: current_section.clone(),
352 scene: current_scene.clone(),
353 speaker: Some(p_key),
354 direction: p_direction,
355 text: line.clone(),
356 direction_type: None,
357 sfx_source: None,
358 sfx_overrides: IndexMap::new(),
359 });
360 debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
361 last_dialogue_idx = Some(entries.len() - 1);
362 pending_speaker = None;
363 continue;
364 }
365
366 if is_divider(&line) {
367 continue;
368 }
369 if is_metadata_section(&line) {
370 in_metadata = true;
371 continue;
372 }
373 if in_metadata {
374 continue;
375 }
376 if line.starts_with("END OF EPISODE") || line.starts_with("END OF PRODUCTION") {
377 break;
378 }
379
380 if let Some(slug) = match_section(&line, §ion_map) {
381 current_section = Some(slug);
382 current_scene = None;
383 seq += 1;
384 entries.push(Entry {
385 seq,
386 kind: "section_header",
387 section: current_section.clone(),
388 scene: None,
389 speaker: None,
390 direction: None,
391 text: line.trim().to_string(),
392 direction_type: None,
393 sfx_source: None,
394 sfx_overrides: IndexMap::new(),
395 });
396 debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
397 last_dialogue_idx = None;
398 continue;
399 }
400
401 if is_scene_header(&line) {
402 let (scene_num, _) = parse_scene_header(&line);
403 if let Some(n) = scene_num {
404 current_scene = Some(format!("scene-{n}"));
405 }
406 seq += 1;
407 entries.push(Entry {
408 seq,
409 kind: "scene_header",
410 section: current_section.clone(),
411 scene: current_scene.clone(),
412 speaker: None,
413 direction: None,
414 text: strip_brackets(&line),
415 direction_type: None,
416 sfx_source: None,
417 sfx_overrides: IndexMap::new(),
418 });
419 debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
420 emit_directions(
421 &line,
422 i,
423 raw_line,
424 &script_slug,
425 ¤t_section,
426 ¤t_scene,
427 &mut seq,
428 &mut entries,
429 &mut debug_line_map,
430 warn,
431 );
432 last_dialogue_idx = None;
433 continue;
434 }
435
436 if is_stage_direction(&line) {
437 emit_directions(
438 &line,
439 i,
440 raw_line,
441 &script_slug,
442 ¤t_section,
443 ¤t_scene,
444 &mut seq,
445 &mut entries,
446 &mut debug_line_map,
447 warn,
448 );
449 last_dialogue_idx = None;
450 continue;
451 }
452
453 if let Some(m) = try_match_speaker(&line, &speakers) {
454 if !m.text.is_empty() {
455 seq += 1;
456 entries.push(Entry {
457 seq,
458 kind: "dialogue",
459 section: current_section.clone(),
460 scene: current_scene.clone(),
461 speaker: Some(m.key),
462 direction: m.direction,
463 text: m.text,
464 direction_type: None,
465 sfx_source: None,
466 sfx_overrides: IndexMap::new(),
467 });
468 debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
469 last_dialogue_idx = Some(entries.len() - 1);
470 } else {
471 pending_speaker = Some((m.key, m.direction));
473 last_dialogue_idx = None;
474 }
475 continue;
476 }
477
478 if let Some(idx) = last_dialogue_idx {
480 if entries[idx].kind == "dialogue" {
481 if line.starts_with('(') && line.ends_with(')') {
483 continue;
484 }
485 entries[idx].text.push(' ');
486 entries[idx].text.push_str(&line);
487 continue;
488 }
489 }
490 }
492
493 Parsed {
494 show,
495 season,
496 episode,
497 title,
498 season_title,
499 source_file: source_file.to_string(),
500 entries,
501 debug_line_map,
502 }
503}
504
505#[allow(clippy::too_many_arguments)]
507fn emit_directions(
508 line: &str,
509 i: usize,
510 raw_line: &str,
511 slug: &str,
512 section: &Option<String>,
513 scene: &Option<String>,
514 seq: &mut i64,
515 entries: &mut Vec<Entry>,
516 debug_line_map: &mut Vec<(usize, String, usize)>,
517 warn: &mut dyn FnMut(String),
518) {
519 for bracket_text in find_brackets(line) {
520 let Hints {
521 clean,
522 source,
523 overrides,
524 } = parse_direction_hint(bracket_text.trim(), slug, warn);
525 let Some(direction_type) = classify_direction(&clean) else {
526 continue;
528 };
529 *seq += 1;
530 entries.push(Entry {
531 seq: *seq,
532 kind: "direction",
533 section: section.clone(),
534 scene: scene.clone(),
535 speaker: None,
536 direction: None,
537 text: clean,
538 direction_type: Some(direction_type),
539 sfx_source: source,
540 sfx_overrides: overrides,
541 });
542 debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
543 }
544}
545
546pub struct SpeakerStat {
548 pub speaker: String,
549 pub lines: usize,
550 pub words: usize,
551 pub chars: usize,
552 pub pct_lines: f64,
553 pub pct_words: f64,
554 pub pct_chars: f64,
555}
556
557pub fn compute_speaker_stats(entries: &[Entry]) -> Vec<SpeakerStat> {
558 let mut accum: IndexMap<String, (usize, usize, usize)> = IndexMap::new();
559 for e in entries.iter().filter(|e| e.kind == "dialogue") {
560 let sp = e.speaker.clone().unwrap_or_default();
561 let slot = accum.entry(sp).or_insert((0, 0, 0));
562 slot.0 += 1;
563 slot.1 += e.text.split_whitespace().count();
564 slot.2 += e.text.chars().count();
565 }
566 let total_lines = accum.values().map(|v| v.0).sum::<usize>().max(1) as f64;
567 let total_words = accum.values().map(|v| v.1).sum::<usize>().max(1) as f64;
568 let total_chars = accum.values().map(|v| v.2).sum::<usize>().max(1) as f64;
569
570 let mut out: Vec<SpeakerStat> = accum
571 .into_iter()
572 .map(|(speaker, (lines, words, chars))| SpeakerStat {
573 speaker,
574 lines,
575 words,
576 chars,
577 pct_lines: py_round1(lines as f64 / total_lines * 100.0),
578 pct_words: py_round1(words as f64 / total_words * 100.0),
579 pct_chars: py_round1(chars as f64 / total_chars * 100.0),
580 })
581 .collect();
582 out.sort_by_key(|a| std::cmp::Reverse(a.lines));
584 out
585}
586
587fn py_round1(x: f64) -> f64 {
590 let s = format!("{:.1}", x);
591 s.parse().unwrap_or(x)
592}
593
594pub fn repr_f64(v: f64) -> String {
596 float_repr(v)
597}
598
599pub fn speakers_for_cast(speakers: &Speakers) -> IndexMap<String, String> {
601 speakers::key_to_display(speakers)
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 fn parse(src: &str) -> Parsed {
609 let opts = ParseOpts {
610 project_type: "podcast",
611 speakers_path: Some(Path::new("/nonexistent")),
612 season_fallback: None,
613 season_title_fallback: None,
614 };
615 parse_script(src, "t.md", &opts, &mut |_| {})
616 }
617
618 #[test]
619 fn header_shapes() {
620 let h =
621 parse_script_header(r#"THE 413 Season 1: Episode 2: "The Booth" Arc: "Holiday Shift""#)
622 .unwrap();
623 assert_eq!(
624 h,
625 Header {
626 show: "THE 413".into(),
627 season: Some(1),
628 episode: 2,
629 title: "The Booth".into(),
630 season_title: Some("Holiday Shift".into()),
631 }
632 );
633 let bare = parse_script_header("My Show Episode 7: Untitled Thing").unwrap();
634 assert_eq!(
635 (bare.season, bare.episode, bare.title.as_str()),
636 (None, 7, "Untitled Thing")
637 );
638 assert!(parse_script_header("Just a line").is_none());
639 }
640
641 #[test]
642 fn leading_blank_lines_do_not_defeat_the_header() {
643 let p = parse("\n\n\nMy Show Episode 3: \"T\"\n\nCOLD OPEN\n");
644 assert_eq!((p.show.as_str(), p.episode), ("My Show", 3));
645 }
646
647 #[test]
648 fn dialogue_direction_and_continuation() {
649 let p = parse(
650 "S Episode 1: \"T\"\n\nCOLD OPEN\n\nSCENE 1: ROOM\n\n\
651 [SFX: DOOR OPENS | door.mp3]\n\n\
652 ADAM (quietly)\nFirst line.\nsecond line continues.\n(beat)\nthird line.\n",
653 );
654 let kinds: Vec<&str> = p.entries.iter().map(|e| e.kind).collect();
655 assert_eq!(
656 kinds,
657 vec!["section_header", "scene_header", "direction", "dialogue"]
658 );
659 let d = &p.entries[2];
660 assert_eq!(d.text, "SFX: DOOR OPENS");
661 assert_eq!(d.sfx_source.as_deref(), Some("SFX/s/door.mp3"));
662 assert_eq!(d.direction_type, Some("SFX"));
663 let dial = &p.entries[3];
664 assert_eq!(dial.speaker.as_deref(), Some("adam"));
665 assert_eq!(dial.direction.as_deref(), Some("quietly"));
666 assert_eq!(
667 dial.text, "First line. second line continues. third line.",
668 "(beat) is dropped"
669 );
670 assert_eq!(dial.scene.as_deref(), Some("scene-1"));
671 assert_eq!(dial.section.as_deref(), Some("cold-open"));
672 }
673
674 #[test]
675 fn direction_between_speaker_and_text_keeps_the_speaker_pending() {
676 let p = parse("S Episode 1: \"T\"\n\nADAM\n[BEAT]\nThe line.\n");
677 let kinds: Vec<&str> = p.entries.iter().map(|e| e.kind).collect();
678 assert_eq!(kinds, vec!["direction", "dialogue"]);
679 assert_eq!(p.entries[1].speaker.as_deref(), Some("adam"));
680 assert_eq!(p.entries[1].text, "The line.");
681 assert_eq!(
682 p.entries.iter().map(|e| e.seq).collect::<Vec<_>>(),
683 vec![1, 2]
684 );
685 }
686
687 #[test]
688 fn standalone_parenthetical_becomes_the_direction() {
689 let p = parse("S Episode 1: \"T\"\n\nADAM\n(shouting)\nHey!\n");
690 assert_eq!(p.entries[0].direction.as_deref(), Some("shouting"));
691 assert_eq!(p.entries[0].text, "Hey!");
692 }
693
694 #[test]
695 fn scene_header_emits_its_embedded_directions() {
696 let p = parse("S Episode 1: \"T\"\n\nSCENE 2A: THE ALLEY [AMBIENCE: rain]\n");
697 assert_eq!(p.entries.len(), 2);
698 assert_eq!(p.entries[0].text, "SCENE 2A: THE ALLEY");
699 assert_eq!(p.entries[0].scene.as_deref(), Some("scene-2A"));
700 assert_eq!(p.entries[1].text, "AMBIENCE: rain");
701 assert_eq!(p.entries[1].direction_type, Some("AMBIENCE"));
702 }
703
704 #[test]
705 fn parsing_stops_at_end_markers_and_metadata() {
706 let p = parse("S Episode 1: \"T\"\n\nADAM Yes.\n\nEND OF EPISODE\n\nADAM No.\n");
707 assert_eq!(p.entries.len(), 1);
708 let m = parse("S Episode 1: \"T\"\n\nADAM Yes.\n\nPRODUCTION NOTES:\n\nADAM No.\n");
709 assert_eq!(m.entries.len(), 1);
710 }
711
712 #[test]
713 fn unrecognized_brackets_are_skipped_without_consuming_a_seq() {
714 let p = parse("S Episode 1: \"T\"\n\n[drawn out]\n[SFX: X]\n");
715 assert_eq!(p.entries.len(), 1);
716 assert_eq!(p.entries[0].seq, 1);
717 }
718
719 #[test]
720 fn stats_count_characters_not_bytes() {
721 let p = parse("S Episode 1: \"T\"\n\nADAM Café.\n");
722 let j = p.to_json();
723 assert_eq!(j["stats"]["characters_for_tts"], 5);
724 assert_eq!(j["stats"]["dialogue_lines"], 1);
725 assert_eq!(j["stats"]["speakers"], serde_json::json!(["adam"]));
726 }
727
728 #[test]
729 fn entry_json_carries_every_model_field() {
730 let p = parse("S Episode 1: \"T\"\n\n[SFX: X | a.mp3 | play_volume_pct=20]\n");
731 let e = &p.to_json()["entries"][0];
732 assert_eq!(e["sfx_source"], "SFX/s/a.mp3");
733 assert_eq!(
734 e["sfx_overrides"],
735 serde_json::json!({"volume_percentage": 20.0})
736 );
737 assert_eq!(e["speaker"], Value::Null);
738 assert!(e.as_object().unwrap().contains_key("direction"));
739 }
740
741 #[test]
742 fn speaker_stats_sorted_and_rounded() {
743 let p = parse("S Episode 1: \"T\"\n\nADAM one two three.\nMAYA hi.\nADAM four.\n");
744 let s = compute_speaker_stats(&p.entries);
745 assert_eq!(s[0].speaker, "adam");
746 assert_eq!((s[0].lines, s[0].words), (2, 4));
747 assert_eq!(s[0].pct_lines, 66.7);
748 assert_eq!(s[1].pct_lines, 33.3);
749 }
750}