Skip to main content

xil_web/
scripts.rs

1//! The Scripts sub-tab: script listing, header analysis and save.
2
3use 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
18/// `_script_choices()`: `scripts/*/*.md` relative to the workspace, else the
19/// flat `scripts/*.md` as absolute paths.
20pub 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/// What Analyze Header fills in.
40#[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    /// The suggested file name, or a warning when the header is not recognised.
48    pub filename: String,
49}
50
51/// `_analyze_script_header(text)`.
52pub 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
83/// `_save_script_file(text, filename)`: write under `scripts/{slug}/` when
84/// that directory exists, else `scripts/`. Never overwrites.
85pub 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    // The name comes from the browser: refuse anything that is not a plain
110    // file name before it is joined onto a path.
111    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}