Skip to main content

xil_core/script/
sections.rs

1//! Section-header maps, one per content type. Port of the `*_SECTIONS`
2//! tables in `XILP001_script_parser.py`.
3
4use indexmap::IndexMap;
5
6/// The legacy map: every show-specific header ever added, merged under all
7/// the type maps so old scripts keep parsing.
8const LEGACY: [(&str, &str); 34] = [
9    ("COLD OPEN", "cold-open"),
10    ("OPENING CREDITS", "opening-credits"),
11    ("CHAPTER ONE", "chapter1"),
12    ("CHAPTER 1", "chapter1"),
13    ("CHAPTER TWO", "chapter2"),
14    ("CHAPTER 2", "chapter2"),
15    ("CHAPTER THREE", "chapter3"),
16    ("CHAPTER 3", "chapter3"),
17    ("ACT ONE", "act1"),
18    ("ACT 1", "act1"),
19    ("ACT TWO", "act2"),
20    ("ACT 2", "act2"),
21    ("ACT THREE", "act3"),
22    ("ACT 3", "act3"),
23    ("ACT FOUR", "act4"),
24    ("ACT 4", "act4"),
25    ("ACT FIVE", "act5"),
26    ("ACT 5", "act5"),
27    ("ACT SIX", "act6"),
28    ("ACT 6", "act6"),
29    ("MID-EPISODE BREAK", "mid-break"),
30    ("CLOSING", "closing"),
31    ("CLOSING — RADIO STATION", "closing"),
32    ("CLOSING — ADAM'S SIGN-OFF", "closing"),
33    ("CLOSING \u{2014} ADAM\u{2019}S SIGN-OFF", "closing"),
34    ("POST-INTERVIEW", "post-interview"),
35    ("POST-INTERVIEW: ADAM & TINA", "post-interview"),
36    ("POST-CREDITS SCENE", "post-credits"),
37    ("DEZ'S CLOSING NARRATION", "dez-closing"),
38    ("DEZ\u{2019}S CLOSING NARRATION", "dez-closing"),
39    ("PRODUCTION NOTES", "production-notes"),
40    // The Woonsocket Wonders intro/outro labels map onto preamble/postamble.
41    ("EPISODE THEME", "preamble"),
42    ("PRE-SHOW MUSIC", "preamble"),
43    ("CLOSING TAG", "postamble"),
44];
45
46const LEGACY_TAIL: [(&str, &str); 2] = [("PREAMBLE", "preamble"), ("POSTAMBLE", "postamble")];
47
48const PODCAST: [(&str, &str); 22] = [
49    ("COLD OPEN", "cold-open"),
50    ("OPENING CREDITS", "opening-credits"),
51    ("ACT ONE", "act1"),
52    ("ACT 1", "act1"),
53    ("ACT TWO", "act2"),
54    ("ACT 2", "act2"),
55    ("ACT THREE", "act3"),
56    ("ACT 3", "act3"),
57    ("ACT FOUR", "act4"),
58    ("ACT 4", "act4"),
59    ("ACT FIVE", "act5"),
60    ("ACT 5", "act5"),
61    ("ACT SIX", "act6"),
62    ("ACT 6", "act6"),
63    ("MID-EPISODE BREAK", "mid-break"),
64    ("CLOSING", "closing"),
65    ("POST-CREDITS SCENE", "post-credits"),
66    ("INTRO", "intro"),
67    ("OUTRO", "outro"),
68    ("PREAMBLE", "preamble"),
69    ("POSTAMBLE", "postamble"),
70    // Padding entry so the array length is a constant; removed below.
71    ("", ""),
72];
73
74const CHAPTER_WORDS: [&str; 30] = [
75    "ONE",
76    "TWO",
77    "THREE",
78    "FOUR",
79    "FIVE",
80    "SIX",
81    "SEVEN",
82    "EIGHT",
83    "NINE",
84    "TEN",
85    "ELEVEN",
86    "TWELVE",
87    "THIRTEEN",
88    "FOURTEEN",
89    "FIFTEEN",
90    "SIXTEEN",
91    "SEVENTEEN",
92    "EIGHTEEN",
93    "NINETEEN",
94    "TWENTY",
95    "TWENTY-ONE",
96    "TWENTY-TWO",
97    "TWENTY-THREE",
98    "TWENTY-FOUR",
99    "TWENTY-FIVE",
100    "TWENTY-SIX",
101    "TWENTY-SEVEN",
102    "TWENTY-EIGHT",
103    "TWENTY-NINE",
104    "THIRTY",
105];
106
107const DRAMA: [(&str, &str); 17] = [
108    ("PROLOGUE", "prologue"),
109    ("EPILOGUE", "epilogue"),
110    ("INTERMISSION", "intermission"),
111    ("ACT ONE", "act1"),
112    ("ACT 1", "act1"),
113    ("ACT TWO", "act2"),
114    ("ACT 2", "act2"),
115    ("ACT THREE", "act3"),
116    ("ACT 3", "act3"),
117    ("ACT FOUR", "act4"),
118    ("ACT 4", "act4"),
119    ("ACT FIVE", "act5"),
120    ("ACT 5", "act5"),
121    ("ACT SIX", "act6"),
122    ("ACT 6", "act6"),
123    ("COLD OPEN", "cold-open"),
124    ("CLOSING", "closing"),
125];
126
127type Map = IndexMap<String, String>;
128
129fn from_pairs(pairs: &[(&str, &str)]) -> Map {
130    pairs
131        .iter()
132        .filter(|(k, _)| !k.is_empty())
133        .map(|(k, v)| (k.to_string(), v.to_string()))
134        .collect()
135}
136
137fn legacy_map() -> Map {
138    let mut m = from_pairs(&LEGACY);
139    m.extend(from_pairs(&LEGACY_TAIL));
140    m
141}
142
143fn podcast_map() -> Map {
144    from_pairs(&PODCAST)
145}
146
147fn audiobook_map() -> Map {
148    let mut m: Map = IndexMap::new();
149    m.insert("PROLOGUE".into(), "prologue".into());
150    m.insert("EPILOGUE".into(), "epilogue".into());
151    m.insert("AUTHOR'S NOTE".into(), "authors-note".into());
152    m.insert("AUTHOR\u{2019}S NOTE".into(), "authors-note".into());
153    for (i, word) in CHAPTER_WORDS.iter().enumerate() {
154        m.insert(format!("CHAPTER {word}"), format!("chapter{}", i + 1));
155    }
156    for n in 1..=30 {
157        m.insert(format!("CHAPTER {n}"), format!("chapter{n}"));
158    }
159    m
160}
161
162fn drama_map() -> Map {
163    let mut m = from_pairs(&DRAMA);
164    m.insert("POST-CREDITS SCENE".into(), "post-credits".into());
165    m
166}
167
168fn special_map() -> Map {
169    let mut m = podcast_map();
170    m.extend(audiobook_map());
171    m.extend(drama_map());
172    for n in 1..=15 {
173        m.insert(format!("SEGMENT {n}"), format!("segment{n}"));
174    }
175    m
176}
177
178/// Section map for a content type: the legacy table with the type's own
179/// entries laid over it.
180pub fn get_section_map(project_type: &str) -> Map {
181    let base = match project_type {
182        "podcast" => podcast_map(),
183        "audiobook" => audiobook_map(),
184        "drama" => drama_map(),
185        "special" => special_map(),
186        _ => return legacy_map(),
187    };
188    let mut merged = legacy_map();
189    merged.extend(base);
190    merged
191}
192
193/// Section slug for a header line, or `None`. A subtitle-qualified header
194/// (`ACT ONE: "Yesterday"`) matches on the part before the first colon.
195pub fn match_section(line: &str, section_map: &Map) -> Option<String> {
196    let stripped = line.trim();
197    if let Some(s) = section_map.get(stripped) {
198        return Some(s.clone());
199    }
200    if let Some((base, _)) = stripped.split_once(':') {
201        if let Some(s) = section_map.get(base.trim()) {
202            return Some(s.clone());
203        }
204    }
205    None
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn podcast_keeps_legacy_entries() {
214        let m = get_section_map("podcast");
215        assert_eq!(m["ACT ONE"], "act1");
216        assert_eq!(
217            m["DEZ'S CLOSING NARRATION"], "dez-closing",
218            "legacy entry survives"
219        );
220        assert_eq!(m["INTRO"], "intro", "type entry present");
221        assert_eq!(m["CHAPTER ONE"], "chapter1", "legacy chapters still parse");
222    }
223
224    #[test]
225    fn audiobook_has_thirty_chapters_both_spellings() {
226        let m = get_section_map("audiobook");
227        assert_eq!(m["CHAPTER ONE"], "chapter1");
228        assert_eq!(m["CHAPTER THIRTY"], "chapter30");
229        assert_eq!(m["CHAPTER 30"], "chapter30");
230        assert_eq!(m["AUTHOR\u{2019}S NOTE"], "authors-note");
231    }
232
233    #[test]
234    fn special_merges_all_three_plus_segments() {
235        let m = get_section_map("special");
236        assert_eq!(m["SEGMENT 15"], "segment15");
237        assert_eq!(m["PROLOGUE"], "prologue");
238        assert_eq!(m["MID-EPISODE BREAK"], "mid-break");
239        assert!(!m.contains_key("SEGMENT 16"));
240    }
241
242    #[test]
243    fn unknown_type_falls_back_to_legacy_only() {
244        let m = get_section_map("nonsense");
245        assert!(m.contains_key("EPISODE THEME"));
246        assert!(!m.contains_key("INTRO"), "podcast-only entry absent");
247    }
248
249    #[test]
250    fn subtitled_headers_match_on_the_stem() {
251        let m = get_section_map("podcast");
252        assert_eq!(match_section("ACT ONE", &m).as_deref(), Some("act1"));
253        assert_eq!(
254            match_section("  ACT ONE: \"Yesterday\"  ", &m).as_deref(),
255            Some("act1")
256        );
257        assert_eq!(
258            match_section("POST-INTERVIEW: ADAM & TINA", &m).as_deref(),
259            Some("post-interview")
260        );
261        assert_eq!(match_section("SOMETHING ELSE", &m), None);
262    }
263}