Skip to main content

xil_core/
journal.rs

1//! Edit journals: `sfx_<tag>_edits.jsonl` beside each SFX config, and
2//! `parsed_<tag>_edits.jsonl` beside each parsed script.
3//!
4//! The timeline editor appends one SFX record per save. A fresh skeleton
5//! from `xil parse` wipes hand-tuned overrides, so the journal is replayed
6//! on top to bring them back. Port of the journal half of `sfx_common.py`.
7//!
8//! The dialogue journal (GUI dialogue-line editor) follows the same
9//! append-only shape but is not replayed automatically on re-parse: SFX cue
10//! keys are stable names, while a dialogue line's `seq` can shift if the
11//! script is restructured, which makes blind seq-based replay riskier.
12
13use std::fs::{self, OpenOptions};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use serde_json::{Map, Value};
18
19use crate::pyjson::{dumps, Style};
20
21/// Fields the timeline editor may set on one cue. Anything else in a record
22/// is ignored on replay.
23pub const SFX_EDIT_FIELDS: [&str; 5] = [
24    "volume_percentage",
25    "ramp_in_seconds",
26    "ramp_out_seconds",
27    "play_duration",
28    "source",
29];
30
31/// `sfx_X.json` → `sfx_X_edits.jsonl`.
32pub fn sfx_edits_path(sfx_path: &Path) -> PathBuf {
33    let s = sfx_path.to_string_lossy();
34    // os.path.splitext: the extension is the last dot in the final component.
35    let stem = match (s.rfind('.'), s.rfind('/')) {
36        (Some(dot), Some(slash)) if dot > slash + 1 => &s[..dot],
37        (Some(dot), None) if dot > 0 => &s[..dot],
38        _ => &s[..],
39    };
40    PathBuf::from(format!("{stem}_edits.jsonl"))
41}
42
43/// Append one per-cue edit record. `None` values mean "clear this override"
44/// and are preserved as JSON null so replay reproduces the save.
45pub fn append_sfx_edit(
46    sfx_path: &Path,
47    key: &str,
48    fields: &Map<String, Value>,
49) -> std::io::Result<()> {
50    let mut record = Map::new();
51    record.insert("ts".into(), Value::String(utc_now()));
52    record.insert("key".into(), Value::String(key.to_string()));
53    record.insert("fields".into(), Value::Object(fields.clone()));
54    append_record(sfx_path, &Value::Object(record))
55}
56
57/// Append one category-defaults record (`scope: "defaults"`, no `key`).
58pub fn append_sfx_defaults_edit(
59    sfx_path: &Path,
60    fields: &Map<String, Value>,
61) -> std::io::Result<()> {
62    let mut record = Map::new();
63    record.insert("ts".into(), Value::String(utc_now()));
64    record.insert("scope".into(), Value::String("defaults".into()));
65    record.insert("fields".into(), Value::Object(fields.clone()));
66    append_record(sfx_path, &Value::Object(record))
67}
68
69/// `parsed_X.json` → `parsed_X_edits.jsonl` — the dialogue-editor sibling of
70/// [`sfx_edits_path`], same stem-swap logic.
71pub fn dialogue_edits_path(parsed_path: &Path) -> PathBuf {
72    sfx_edits_path(parsed_path)
73}
74
75/// Append one dialogue-line edit record: `{ts, seq, fields: {speaker, text}}`.
76/// Unlike the SFX journal, both fields are always set — a dialogue line has
77/// no "clear this override" state.
78pub fn append_dialogue_edit(
79    parsed_path: &Path,
80    seq: i64,
81    speaker: &str,
82    text: &str,
83) -> std::io::Result<()> {
84    let mut fields = Map::new();
85    fields.insert("speaker".into(), Value::String(speaker.to_string()));
86    fields.insert("text".into(), Value::String(text.to_string()));
87    let mut record = Map::new();
88    record.insert("ts".into(), Value::String(utc_now()));
89    record.insert("seq".into(), Value::from(seq));
90    record.insert("fields".into(), Value::Object(fields));
91    append_record(parsed_path, &Value::Object(record))
92}
93
94fn append_record(sfx_path: &Path, record: &Value) -> std::io::Result<()> {
95    let journal = sfx_edits_path(sfx_path);
96    if let Some(d) = journal.parent() {
97        if !d.as_os_str().is_empty() {
98            fs::create_dir_all(d)?;
99        }
100    }
101    let mut f = OpenOptions::new()
102        .append(true)
103        .create(true)
104        .open(&journal)?;
105    writeln!(f, "{}", dumps(record, Style::COMPACT_UTF8))
106}
107
108/// `datetime.now(UTC).isoformat(timespec="seconds")` — `2026-09-11T18:30:00+00:00`.
109fn utc_now() -> String {
110    chrono::Utc::now()
111        .format("%Y-%m-%dT%H:%M:%S+00:00")
112        .to_string()
113}
114
115/// Outcome of a replay: how many records were applied, and which keys did
116/// not exist in the config (a renamed direction leaves a harmless orphan).
117pub struct Replay {
118    pub applied: usize,
119    pub orphans: Vec<String>,
120}
121
122/// Reapply the journal onto the config at `sfx_path`, in journal order
123/// (last write wins). `(0, [])` when no journal exists.
124///
125/// `warn` receives the message Python logs when a journaled `source`
126/// overrides a different one — the operator has to see which asset won.
127pub fn replay_sfx_edits(
128    sfx_path: &Path,
129    dry_run: bool,
130    warn: &mut dyn FnMut(String),
131) -> std::io::Result<Replay> {
132    let journal = sfx_edits_path(sfx_path);
133    if !journal.exists() {
134        return Ok(Replay {
135            applied: 0,
136            orphans: Vec::new(),
137        });
138    }
139
140    let text = fs::read_to_string(sfx_path)?;
141    let mut data: Value = serde_json::from_str(&text).unwrap_or(Value::Object(Map::new()));
142    if !data.is_object() {
143        data = Value::Object(Map::new());
144    }
145    let obj = data.as_object_mut().expect("object");
146    obj.entry("effects")
147        .or_insert_with(|| Value::Object(Map::new()));
148
149    let mut applied = 0usize;
150    let mut orphans: Vec<String> = Vec::new();
151
152    let journal_text = fs::read_to_string(&journal)?;
153    for (i, raw) in journal_text.lines().enumerate() {
154        let line = raw.trim();
155        if line.is_empty() {
156            continue;
157        }
158        let Ok(record) = serde_json::from_str::<Value>(line) else {
159            warn(format!(
160                "  Skipping malformed journal line {} in {}",
161                i + 1,
162                journal.display()
163            ));
164            continue;
165        };
166
167        if record.get("scope").and_then(Value::as_str) == Some("defaults") {
168            let fields = record
169                .get("fields")
170                .and_then(Value::as_object)
171                .cloned()
172                .unwrap_or_default();
173            let defaults = obj
174                .entry("defaults")
175                .or_insert_with(|| Value::Object(Map::new()))
176                .as_object_mut()
177                .expect("defaults object");
178            for (k, v) in fields {
179                if v.is_null() {
180                    defaults.remove(&k);
181                } else {
182                    defaults.insert(k, v);
183                }
184            }
185            applied += 1;
186            continue;
187        }
188
189        let (Some(key), Some(fields)) = (
190            record
191                .get("key")
192                .and_then(Value::as_str)
193                .map(str::to_string),
194            record.get("fields").and_then(Value::as_object).cloned(),
195        ) else {
196            warn(format!(
197                "  Skipping malformed journal line {} in {}",
198                i + 1,
199                journal.display()
200            ));
201            continue;
202        };
203
204        let effects = obj["effects"].as_object_mut().expect("effects object");
205        if !effects.contains_key(&key) && !orphans.contains(&key) {
206            orphans.push(key.clone());
207        }
208        let effect = effects
209            .entry(key.clone())
210            .or_insert_with(|| Value::Object(Map::new()));
211        let Some(effect) = effect.as_object_mut() else {
212            applied += 1;
213            continue;
214        };
215        let mut warnings = Vec::new();
216        for field in SFX_EDIT_FIELDS {
217            let Some(new) = fields.get(field) else {
218                continue;
219            };
220            if new.is_null() {
221                effect.remove(field);
222                continue;
223            }
224            if field == "source" {
225                if let Some(cur) = effect.get(field).filter(|c| !c.is_null()) {
226                    if cur != new {
227                        warnings.push(format!(
228                            "  Journal overrides script hint for '{key}': {} -> {} \
229                             (re-run xil sfx-hydrate --force to make the script win)",
230                            cur.as_str().unwrap_or_default(),
231                            new.as_str().unwrap_or_default()
232                        ));
233                    }
234                }
235            }
236            effect.insert(field.to_string(), new.clone());
237        }
238        for w in warnings {
239            warn(w);
240        }
241        applied += 1;
242    }
243
244    if applied > 0 && !dry_run {
245        // Note the trailing newline: replay writes one, the fresh skeleton
246        // in `xil parse` does not. Both behaviours are load-bearing for
247        // byte-identical output.
248        fs::write(sfx_path, format!("{}\n", dumps(&data, Style::INDENT2_UTF8)))?;
249    }
250    Ok(Replay { applied, orphans })
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use serde_json::json;
257
258    fn write(p: &Path, s: &str) {
259        fs::create_dir_all(p.parent().unwrap()).unwrap();
260        fs::write(p, s).unwrap();
261    }
262
263    #[test]
264    fn journal_path_swaps_the_extension() {
265        assert_eq!(
266            sfx_edits_path(Path::new("configs/s/sfx_S01E01.json")),
267            Path::new("configs/s/sfx_S01E01_edits.jsonl")
268        );
269        assert_eq!(
270            sfx_edits_path(Path::new("sfx_X")),
271            Path::new("sfx_X_edits.jsonl")
272        );
273        assert_eq!(
274            sfx_edits_path(Path::new("a.b/sfx_X")),
275            Path::new("a.b/sfx_X_edits.jsonl")
276        );
277    }
278
279    #[test]
280    fn replay_sets_clears_and_reports_orphans() {
281        let tmp = tempfile::tempdir().unwrap();
282        let cfg = tmp.path().join("sfx_S01E01.json");
283        write(
284            &cfg,
285            r#"{"effects":{"BEAT":{"type":"silence","volume_percentage":50}}}"#,
286        );
287        let j = sfx_edits_path(&cfg);
288        write(
289            &j,
290            concat!(
291                r#"{"ts":"t","key":"BEAT","fields":{"volume_percentage":null,"play_duration":25}}"#,
292                "\n",
293                r#"{"ts":"t","key":"GONE","fields":{"source":"SFX/x.mp3"}}"#,
294                "\n",
295                "\n",
296                r#"not json"#,
297                "\n",
298                r#"{"ts":"t","scope":"defaults","fields":{"music_volume_percentage":40}}"#,
299                "\n",
300            ),
301        );
302        let mut warns = Vec::new();
303        let r = replay_sfx_edits(&cfg, false, &mut |m| warns.push(m)).unwrap();
304        assert_eq!(r.applied, 3);
305        assert_eq!(r.orphans, vec!["GONE"]);
306        assert_eq!(warns.len(), 1, "the unparseable line warns");
307        let out: Value = serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
308        assert_eq!(
309            out["effects"]["BEAT"],
310            json!({"type": "silence", "play_duration": 25})
311        );
312        assert_eq!(out["effects"]["GONE"], json!({"source": "SFX/x.mp3"}));
313        assert_eq!(out["defaults"], json!({"music_volume_percentage": 40}));
314        assert!(fs::read_to_string(&cfg).unwrap().ends_with("}\n"));
315    }
316
317    #[test]
318    fn replay_warns_when_journal_beats_a_script_hint() {
319        let tmp = tempfile::tempdir().unwrap();
320        let cfg = tmp.path().join("sfx_a.json");
321        write(
322            &cfg,
323            r#"{"effects":{"MUSIC: X":{"source":"SFX/from-script.mp3"}}}"#,
324        );
325        write(
326            &sfx_edits_path(&cfg),
327            "{\"key\":\"MUSIC: X\",\"fields\":{\"source\":\"SFX/from-journal.mp3\"}}\n",
328        );
329        let mut warns = Vec::new();
330        replay_sfx_edits(&cfg, false, &mut |m| warns.push(m)).unwrap();
331        assert_eq!(warns.len(), 1);
332        assert!(
333            warns[0].contains("SFX/from-script.mp3 -> SFX/from-journal.mp3"),
334            "{}",
335            warns[0]
336        );
337    }
338
339    #[test]
340    fn dry_run_leaves_the_file_alone() {
341        let tmp = tempfile::tempdir().unwrap();
342        let cfg = tmp.path().join("sfx_a.json");
343        let before = r#"{"effects":{"BEAT":{}}}"#;
344        write(&cfg, before);
345        write(
346            &sfx_edits_path(&cfg),
347            "{\"key\":\"BEAT\",\"fields\":{\"play_duration\":10}}\n",
348        );
349        let r = replay_sfx_edits(&cfg, true, &mut |_| {}).unwrap();
350        assert_eq!(r.applied, 1);
351        assert_eq!(fs::read_to_string(&cfg).unwrap(), before);
352    }
353
354    #[test]
355    fn no_journal_is_a_no_op() {
356        let tmp = tempfile::tempdir().unwrap();
357        let cfg = tmp.path().join("sfx_a.json");
358        write(&cfg, "{}");
359        let r = replay_sfx_edits(&cfg, false, &mut |_| panic!("no warnings expected")).unwrap();
360        assert_eq!((r.applied, r.orphans.len()), (0, 0));
361    }
362
363    #[test]
364    fn dialogue_edit_appends_seq_and_fields() {
365        let tmp = tempfile::tempdir().unwrap();
366        let parsed = tmp.path().join("parsed_S01E01.json");
367        append_dialogue_edit(&parsed, 47, "adam", "Well — hi").unwrap();
368        let text = fs::read_to_string(dialogue_edits_path(&parsed)).unwrap();
369        let lines: Vec<&str> = text.lines().collect();
370        assert_eq!(lines.len(), 1);
371        let record: Value = serde_json::from_str(lines[0]).unwrap();
372        assert_eq!(record["seq"], 47);
373        assert_eq!(record["fields"]["speaker"], "adam");
374        assert_eq!(record["fields"]["text"], "Well — hi");
375        assert_eq!(
376            dialogue_edits_path(&parsed),
377            tmp.path().join("parsed_S01E01_edits.jsonl")
378        );
379    }
380
381    #[test]
382    fn append_writes_one_compact_line_per_record() {
383        let tmp = tempfile::tempdir().unwrap();
384        let cfg = tmp.path().join("sfx_a.json");
385        let mut fields = Map::new();
386        fields.insert("source".into(), Value::String("SFX/é.mp3".into()));
387        fields.insert("volume_percentage".into(), Value::Null);
388        append_sfx_edit(&cfg, "SFX: X", &fields).unwrap();
389        append_sfx_edit(&cfg, "SFX: Y", &fields).unwrap();
390        let text = fs::read_to_string(sfx_edits_path(&cfg)).unwrap();
391        let lines: Vec<&str> = text.lines().collect();
392        assert_eq!(lines.len(), 2);
393        assert!(lines[0].contains(r#""key": "SFX: X""#), "{}", lines[0]);
394        assert!(
395            lines[0].contains("é.mp3"),
396            "ensure_ascii=False is preserved"
397        );
398        assert!(lines[0].contains(r#""volume_percentage": null"#));
399    }
400}