Skip to main content

xil/cmd/
sfx_restore.rs

1//! `xil sfx-restore` — replay the timeline edit journal onto an episode's
2//! SFX config. Port of `XILU020_sfx_restore.py`.
3
4use std::ffi::OsString;
5use std::path::PathBuf;
6
7use clap::Parser;
8use xil_core::journal::{replay_sfx_edits, sfx_edits_path};
9use xil_core::script::hints::py_repr;
10use xil_core::workspace::{derive_paths, resolve_slug};
11use xil_core::{banner, log};
12
13const SCRIPT_NAME: &str = "XILU020_sfx_restore";
14
15#[derive(Parser)]
16#[command(
17    name = "xil-sfx-restore",
18    about = "Reapply journaled timeline sound edits (sfx_<tag>_edits.jsonl) onto the episode's SFX config — recovery for a cleared or regenerated sfx_<tag>.json."
19)]
20struct Args {
21    /// Episode tag (e.g. S04E04)
22    #[arg(long, required_unless_present = "tag", conflicts_with = "tag")]
23    episode: Option<String>,
24    /// Raw non-episodic tag (e.g. V01C03)
25    #[arg(long)]
26    tag: Option<String>,
27    /// Show name override (default: from project.json)
28    #[arg(long)]
29    show: Option<String>,
30    /// Override SFX config path
31    #[arg(long)]
32    sfx: Option<PathBuf>,
33    /// Report what would be reapplied without writing
34    #[arg(long)]
35    dry_run: bool,
36}
37
38fn execute(a: &Args, tag: &str) -> anyhow::Result<i32> {
39    let slug = resolve_slug(a.show.as_deref(), "project.json");
40    let sfx_path = a
41        .sfx
42        .clone()
43        .unwrap_or_else(|| derive_paths(&slug, tag)["sfx"].clone());
44
45    if !sfx_path.exists() {
46        log::error(&format!("SFX config not found: {}", sfx_path.display()));
47        log::info(
48            "Run `xil parse --episode TAG` first to generate it (the journal is reapplied automatically).",
49        );
50        return Ok(1);
51    }
52    let journal = sfx_edits_path(&sfx_path);
53    if !journal.exists() {
54        log::error(&format!("No edit journal found: {}", journal.display()));
55        log::info(
56            "The journal is created the first time a sound profile is saved in the GUI timeline editor.",
57        );
58        return Ok(1);
59    }
60
61    let replay = replay_sfx_edits(&sfx_path, a.dry_run, &mut |m| log::warning(&m))?;
62    let action = if a.dry_run {
63        "Would reapply"
64    } else {
65        "Reapplied"
66    };
67    log::info(&format!(
68        "  {action} {} edit record(s) from {}",
69        replay.applied,
70        journal.display()
71    ));
72    for key in &replay.orphans {
73        log::warning(&format!(
74            "  Orphaned key (not in current effects): {}",
75            py_repr(key)
76        ));
77    }
78    if a.dry_run && replay.applied > 0 {
79        log::info("  Re-run without --dry-run to apply changes.");
80    }
81    Ok(0)
82}
83
84pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
85    log::init("sfx-restore");
86    let a: Args = match super::parse_or_exit("xil-sfx-restore", args) {
87        Ok(a) => a,
88        Err(code) => return Ok(code),
89    };
90    let tag = a
91        .episode
92        .clone()
93        .or_else(|| a.tag.clone())
94        .unwrap_or_default();
95    let _banner = banner::begin(SCRIPT_NAME, &super::argv_line(args));
96    execute(&a, &tag)
97}
98
99/// The clap definition behind `--help`, for man pages.
100pub fn command() -> clap::Command {
101    <Args as clap::CommandFactory>::command()
102}