1use std::fs;
4use std::sync::LazyLock;
5
6use regex::Regex;
7use xil_core::fsutil::{glob_children, relpath, sort_py};
8use xil_core::script::parse_script_header;
9use xil_core::workspace::{show_slug, workspace_root};
10
11use crate::activity;
12use crate::episodes::child_dirs;
13
14static NON_ALNUM: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^A-Za-z0-9]+").unwrap());
15static FILENAME_SLUG: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"^(?:[A-Z]\d+[A-Z]\d+_)?([a-z0-9]+)_").unwrap());
17
18pub fn script_choices() -> Vec<String> {
21 let root = workspace_root();
22 let mut per_show = Vec::new();
23 for dir in child_dirs(&root.join("scripts")) {
24 per_show.extend(glob_children(&dir, "", ".md"));
25 }
26 sort_py(&mut per_show);
27 if !per_show.is_empty() {
28 return per_show
29 .iter()
30 .map(|p| relpath(p, &root).to_string_lossy().into_owned())
31 .collect();
32 }
33 glob_children(&root.join("scripts"), "", ".md")
34 .iter()
35 .map(|p| p.to_string_lossy().into_owned())
36 .collect()
37}
38
39#[derive(Clone, Debug, Default, PartialEq)]
41pub struct HeaderFields {
42 pub show: String,
43 pub season: String,
44 pub episode: String,
45 pub title: String,
46 pub arc: String,
47 pub filename: String,
49}
50
51pub fn analyze_script_header(text: &str) -> HeaderFields {
53 let Some(first) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
54 return HeaderFields::default();
55 };
56 activity::log("ANALYZE header");
57 let Some(h) = parse_script_header(first) else {
58 return HeaderFields {
59 filename: "⚠️ Header not recognized — expected: SHOW Season N: Episode N: \"Title\""
60 .into(),
61 ..HeaderFields::default()
62 };
63 };
64 let slug = show_slug(&h.show);
65 let safe_title = NON_ALNUM
66 .replace_all(&h.title, "_")
67 .trim_matches('_')
68 .to_string();
69 let season = h
70 .season
71 .map(|s| format!("{s:02}"))
72 .unwrap_or_else(|| "XX".into());
73 HeaderFields {
74 filename: format!("S{season}E{:02}_{slug}_{safe_title}_v1.md", h.episode),
75 show: h.show,
76 season: h.season.map(|s| s.to_string()).unwrap_or_default(),
77 episode: h.episode.to_string(),
78 title: h.title,
79 arc: h.season_title.unwrap_or_default(),
80 }
81}
82
83pub fn save_script_file(text: &str, filename: &str) -> String {
86 if text.trim().is_empty() {
87 return "⚠️ No script content to save.".into();
88 }
89 let mut filename = filename.trim().to_string();
90 if filename.is_empty() {
91 return "⚠️ Filename is empty — run Analyze Header first.".into();
92 }
93 if !filename.ends_with(".md") {
94 filename.push_str(".md");
95 }
96 let root = workspace_root();
97 let slug = FILENAME_SLUG
98 .captures(&filename)
99 .map(|c| c[1].to_string())
100 .unwrap_or_default();
101 let (dir, rel) = if !slug.is_empty() && root.join("scripts").join(&slug).is_dir() {
102 (
103 root.join("scripts").join(&slug),
104 format!("scripts/{slug}/{filename}"),
105 )
106 } else {
107 (root.join("scripts"), format!("scripts/{filename}"))
108 };
109 if filename.contains('/')
112 || filename.contains('\\')
113 || filename.contains('\0')
114 || filename.starts_with("..")
115 {
116 return format!("⚠️ Invalid filename: {filename}");
117 }
118 if let Err(e) = fs::create_dir_all(&dir) {
119 return format!("⚠️ {e}");
120 }
121 let dest = dir.join(&filename);
122 if dest.exists() {
123 return format!(
124 "⚠️ Already exists: {rel} — edit the filename above to save a new version."
125 );
126 }
127 if let Err(e) = fs::write(&dest, text) {
128 return format!("⚠️ {e}");
129 }
130 activity::log(&format!("SAVE script → {rel}"));
131 format!("✅ Saved: {rel}")
132}