Skip to main content

xil_web/
configs.rs

1//! Config file listing, the workspace path boundary, and JSON load/save for
2//! the Project, Speakers, Cast Config and SFX Config tabs.
3
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6use std::sync::LazyLock;
7
8use regex::Regex;
9use serde_json::Value;
10use xil_core::fsutil::{basename, glob_children};
11use xil_core::pyjson::{dumps, Style};
12use xil_core::workspace::{active_show, show_slug, workspace_root};
13
14use crate::activity;
15use crate::episodes::{child_dirs, is_legacy_cast_name};
16
17static LEGACY_SFX_RE: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(r"^sfx_(.+?)_([A-Z0-9]+)\.json$").unwrap());
19
20fn strings(paths: Vec<PathBuf>) -> Vec<String> {
21    paths
22        .into_iter()
23        .map(|p| p.to_string_lossy().into_owned())
24        .collect()
25}
26
27/// `_find_speakers_configs()`.
28pub fn find_speakers_configs() -> Vec<String> {
29    let root = workspace_root();
30    let mut v: Vec<PathBuf> = child_dirs(&root.join("configs"))
31        .into_iter()
32        .map(|d| d.join("speakers.json"))
33        .filter(|p| p.is_file())
34        .collect();
35    let legacy = root.join("speakers.json");
36    if legacy.exists() {
37        v.push(legacy);
38    }
39    strings(v)
40}
41
42/// `_find_cast_configs()`.
43pub fn find_cast_configs() -> Vec<String> {
44    let root = workspace_root();
45    let mut v = Vec::new();
46    for d in child_dirs(&root.join("configs")) {
47        v.extend(glob_children(&d, "cast_", ".json"));
48    }
49    v.sort();
50    v.extend(
51        glob_children(&root, "cast_", ".json")
52            .into_iter()
53            .filter(|p| is_legacy_cast_name(&basename(p))),
54    );
55    strings(v)
56}
57
58/// `_find_sfx_configs()`.
59pub fn find_sfx_configs() -> Vec<String> {
60    let root = workspace_root();
61    let mut v = Vec::new();
62    for d in child_dirs(&root.join("configs")) {
63        v.extend(glob_children(&d, "sfx_", ".json"));
64    }
65    v.sort();
66    v.extend(
67        glob_children(&root, "sfx_", ".json")
68            .into_iter()
69            .filter(|p| LEGACY_SFX_RE.is_match(&basename(p))),
70    );
71    strings(v)
72}
73
74/// `Path.resolve()` without `strict`: symlinks followed as far as the path
75/// exists, the rest normalised lexically.
76pub fn resolve_lenient(path: &Path) -> PathBuf {
77    let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
78    let mut existing = abs.clone();
79    let mut rest = Vec::new();
80    while !existing.exists() {
81        match (existing.parent(), existing.file_name()) {
82            (Some(parent), Some(name)) => {
83                rest.push(name.to_os_string());
84                existing = parent.to_path_buf();
85            }
86            _ => break,
87        }
88    }
89    let mut out = fs::canonicalize(&existing).unwrap_or(existing);
90    for name in rest.into_iter().rev() {
91        out.push(name);
92    }
93    let mut norm = PathBuf::new();
94    for c in out.components() {
95        match c {
96            Component::ParentDir => {
97                norm.pop();
98            }
99            Component::CurDir => {}
100            other => norm.push(other),
101        }
102    }
103    norm
104}
105
106/// `_check_workspace_path(path)`: `Err` when `path` resolves outside the
107/// workspace root.
108pub fn check_workspace_path(path: &Path) -> Result<(), String> {
109    let workspace = resolve_lenient(&workspace_root());
110    if resolve_lenient(path).starts_with(&workspace) {
111        Ok(())
112    } else {
113        Err(format!(
114            "Path is outside the workspace root: {}",
115            xil_core::script::hints::py_repr(&path.to_string_lossy())
116        ))
117    }
118}
119
120/// `_load_config_file(path, label)`: the file's text for an editor, or a
121/// `//` comment explaining why not.
122pub fn load_config_file(path: &str, label: &str) -> String {
123    if path.is_empty() {
124        return String::new();
125    }
126    if let Err(e) = check_workspace_path(Path::new(path)) {
127        return format!("// {e}");
128    }
129    activity::log(&format!("SELECT {label} → {path}"));
130    if !Path::new(path).exists() {
131        return format!("// File not found: {path}");
132    }
133    fs::read_to_string(path).unwrap_or_else(|e| format!("// {e}"))
134}
135
136/// `json.dump(data, f, indent=2)` plus a newline.
137fn write_json(path: &Path, data: &Value) -> std::io::Result<()> {
138    fs::write(path, dumps(data, Style::INDENT2) + "\n")
139}
140
141/// `save_cast_config` / `save_speakers_config` / `save_sfx_config`: validate,
142/// pretty-print and write. `what` names the file in the activity log.
143pub fn save_config_file(path: &str, text: &str, what: &str) -> String {
144    if path.is_empty() {
145        return "No file selected.".into();
146    }
147    let p = Path::new(path);
148    if let Err(e) = check_workspace_path(p) {
149        return e;
150    }
151    let data: Value = match serde_json::from_str(text) {
152        Ok(d) => d,
153        Err(e) => return format!("Invalid JSON — not saved: {e}"),
154    };
155    if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
156        if let Err(e) = fs::create_dir_all(dir) {
157            return e.to_string();
158        }
159    }
160    if let Err(e) = write_json(p, &data) {
161        return e.to_string();
162    }
163    activity::log(&format!("SAVE {what} → {path}"));
164    format!("Saved {path}")
165}
166
167/// `_get_project_json_path()`: the active show's project.json when it exists,
168/// else the legacy root one.
169pub fn project_json_path() -> PathBuf {
170    let root = workspace_root();
171    if let Some(slug) = active_show().filter(|s| !s.is_empty()) {
172        let candidate = root.join("configs").join(slug).join("project.json");
173        if candidate.exists() {
174            return candidate;
175        }
176    }
177    root.join("project.json")
178}
179
180/// `load_project_json()` → `(content, path)`.
181pub fn load_project_json() -> (String, PathBuf) {
182    let path = project_json_path();
183    match fs::read_to_string(&path) {
184        Ok(s) => (s, path),
185        Err(_) => ("{\n  \"show\": \"\",\n  \"season\": 1\n}".into(), path),
186    }
187}
188
189/// `save_project_json(text)`.
190pub fn save_project_json(text: &str) -> String {
191    let data: Value = match serde_json::from_str(text) {
192        Ok(d) => d,
193        Err(e) => return format!("Invalid JSON — not saved: {e}"),
194    };
195    let path = project_json_path();
196    if let Err(e) = write_json(&path, &data) {
197        return e.to_string();
198    }
199    activity::log(&format!("SAVE project.json → {}", path.display()));
200    format!("Saved {}", path.display())
201}
202
203/// `_list_available_shows()`: show names from `configs/*/project.json`.
204pub fn list_available_shows() -> Vec<String> {
205    let configs = workspace_root().join("configs");
206    let mut names: Vec<String> = fs::read_dir(&configs)
207        .map(|rd| {
208            rd.filter_map(Result::ok)
209                .map(|e| e.file_name().to_string_lossy().into_owned())
210                .collect()
211        })
212        .unwrap_or_default();
213    names.sort();
214    names
215        .into_iter()
216        .filter_map(|name| {
217            let pj = configs.join(&name).join("project.json");
218            if !configs.join(&name).is_dir() || !pj.exists() {
219                return None;
220            }
221            let show = fs::read_to_string(&pj)
222                .ok()
223                .and_then(|s| serde_json::from_str::<Value>(&s).ok())
224                .map(|d| match d.get("show") {
225                    Some(Value::String(s)) => s.clone(),
226                    _ => name.clone(),
227                });
228            Some(show.unwrap_or(name))
229        })
230        .collect()
231}
232
233/// The show whose slug is the active one, for the Setup dropdown.
234pub fn active_show_name(shows: &[String]) -> Option<String> {
235    let active = active_show()?;
236    shows.iter().find(|n| show_slug(n) == active).cloned()
237}