1use indexmap::IndexMap;
5
6const 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 ("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 ("", ""),
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
178pub 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
193pub 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}