Skip to main content

xil_web/
parsed.rs

1//! The Edit Parsed JSON tab: load and save `parsed/{slug}/parsed_{tag}.json`
2//! for an episode chosen from the usual Episode dropdown.
3//!
4//! The config editors in [`crate::configs`] key off a file path the user picks
5//! from a list; this one derives its path from `(slug, tag)` instead, so it
6//! shares the Episode dropdown with Run Stage and Audio Preview.
7//!
8//! Saving writes the file the way `xil parse` writes it —
9//! [`Style::INDENT2_UTF8`] with no trailing newline — rather than the
10//! [`Style::INDENT2`] the config editors use, so a hand-edit and a re-parse
11//! produce the same bytes and the non-ASCII punctuation in a script (`—`,
12//! `“`) is not rewritten as `\uXXXX` escapes.
13
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use serde_json::Value;
18use xil_core::pyjson::{dumps, Style};
19use xil_core::workspace::derive_paths;
20
21use crate::configs::check_workspace_path;
22use crate::{activity, episodes};
23
24/// The parsed JSON path behind an Episode dropdown label, or the reason there
25/// is none.
26pub fn parsed_path(ep: &str) -> Result<PathBuf, String> {
27    let (slug, tag) = episodes::parse_choice(ep);
28    if slug.is_empty() {
29        return Err("Select an episode first.".into());
30    }
31    if tag.is_empty() {
32        return Err("Select an episode, not a show.".into());
33    }
34    let path = derive_paths(&slug, &tag)["parsed"].clone();
35    check_workspace_path(&path)?;
36    Ok(path)
37}
38
39/// The file's text for the editor, or a `//` comment explaining why not.
40/// Mirrors `configs::load_config_file`.
41pub fn load(ep: &str) -> String {
42    let path = match parsed_path(ep) {
43        Ok(p) => p,
44        Err(e) => return format!("// {e}"),
45    };
46    activity::log(&format!("SELECT parsed → {}", path.display()));
47    if !path.exists() {
48        return format!("// File not found: {}", path.display());
49    }
50    fs::read_to_string(&path).unwrap_or_else(|e| format!("// {e}"))
51}
52
53/// `{name}.bak` beside the file itself, so `parsed_S01E01.json` backs up to
54/// `parsed_S01E01.json.bak`.
55fn backup_path(path: &Path) -> PathBuf {
56    let mut name = path.file_name().unwrap_or_default().to_os_string();
57    name.push(".bak");
58    path.with_file_name(name)
59}
60
61/// Validate, back up and write. Returns the status line.
62pub fn save(ep: &str, text: &str) -> String {
63    let path = match parsed_path(ep) {
64        Ok(p) => p,
65        Err(e) => return e,
66    };
67    let data: Value = match serde_json::from_str(text) {
68        Ok(d) => d,
69        Err(e) => return format!("Invalid JSON — not saved: {e}"),
70    };
71    // A failed backup aborts the save: never overwrite what we could not copy.
72    if path.exists() {
73        if let Err(e) = fs::copy(&path, backup_path(&path)) {
74            return format!("Backup failed — not saved: {e}");
75        }
76    }
77    if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
78        if let Err(e) = fs::create_dir_all(dir) {
79            return e.to_string();
80        }
81    }
82    if let Err(e) = fs::write(&path, dumps(&data, Style::INDENT2_UTF8)) {
83        return e.to_string();
84    }
85    activity::log(&format!("SAVE parsed → {}", path.display()));
86    format!("Saved {}", path.display())
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn backup_sits_beside_the_file() {
95        assert_eq!(
96            backup_path(Path::new("/ws/parsed/the413/parsed_S01E01.json")),
97            PathBuf::from("/ws/parsed/the413/parsed_S01E01.json.bak")
98        );
99    }
100
101    #[test]
102    fn a_show_stub_and_an_unsafe_slug_have_no_path() {
103        assert_eq!(
104            parsed_path("mypodcast  [show]  —  My Podcast"),
105            Err("Select an episode, not a show.".into())
106        );
107        assert_eq!(
108            parsed_path("../etc  S01E01"),
109            Err("Select an episode first.".into())
110        );
111        assert_eq!(parsed_path(""), Err("Select an episode first.".into()));
112    }
113}