Skip to main content

xil_web/
entries.rs

1//! The Dialogue sub-tab and the Timeline's dialogue-edit modal: read,
2//! search and edit one dialogue line's speaker and text in
3//! `parsed/{slug}/parsed_{tag}.json`.
4//!
5//! Both surfaces call [`save_entry`] — there is exactly one place that
6//! mutates a dialogue line. Scope is deliberately narrow: only entries with
7//! `"type": "dialogue"` can be edited here; sound cues, scene headers and
8//! everything else stay the Raw JSON tab's job.
9
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use std::collections::HashMap;
14
15use serde_json::{Map, Value};
16use xil_core::fsutil::basename;
17use xil_core::journal::{append_dialogue_edit, dialogue_edits_path};
18use xil_core::pyjson::{dumps, Style};
19use xil_core::script::{nfc_normalize, speakers::load_speakers_registry, stats_from_entries_json};
20use xil_core::workspace::{derive_paths, workspace_root};
21
22use crate::activity;
23use crate::configs::check_workspace_path;
24use crate::episodes::is_safe_slug_or_tag;
25
26/// One dialogue row, as surfaced to either editor.
27#[derive(Clone, Debug, PartialEq)]
28pub struct DialogueRow {
29    pub seq: i64,
30    pub speaker: String,
31    pub text: String,
32}
33
34/// `(slug, tag)` → the parsed JSON path, jailed — the slug/tag-keyed sibling
35/// of [`crate::parsed::parsed_path`], for callers that already have
36/// `(slug, tag)` (the Timeline's JS globals) instead of an Episode-dropdown
37/// label.
38pub fn path_for(slug: &str, tag: &str) -> Result<PathBuf, String> {
39    if !(is_safe_slug_or_tag(slug) && is_safe_slug_or_tag(tag)) {
40        return Err("invalid slug or tag".into());
41    }
42    let path = derive_paths(slug, tag)["parsed"].clone();
43    check_workspace_path(&path)?;
44    Ok(path)
45}
46
47fn load_entries(path: &Path) -> Result<(Map<String, Value>, Vec<Value>), String> {
48    let text = fs::read_to_string(path).map_err(|e| e.to_string())?;
49    let data: Value = serde_json::from_str(&text)
50        .map_err(|e| format!("{} is not valid JSON: {e}", path.display()))?;
51    let Value::Object(top) = data else {
52        return Err(format!("{} is not a JSON object", path.display()));
53    };
54    let entries = top
55        .get("entries")
56        .and_then(Value::as_array)
57        .cloned()
58        .unwrap_or_default();
59    Ok((top, entries))
60}
61
62/// `{name}.bak` beside the file, matching [`crate::parsed::save`]'s scheme.
63fn backup_path(path: &Path) -> PathBuf {
64    let mut name = path.file_name().unwrap_or_default().to_os_string();
65    name.push(".bak");
66    path.with_file_name(name)
67}
68
69/// `configs/<slug>/speakers.json` (legacy fallback: the workspace root), as
70/// dropdown-ready `(label, key)` pairs in the file's own order. Empty when
71/// no speakers file exists yet for this show — callers then accept any
72/// speaker key rather than blocking the editor on a file that hasn't been
73/// authored.
74pub fn speaker_choices(slug: &str) -> Vec<(String, String)> {
75    let root = workspace_root();
76    let candidate = root.join("configs").join(slug).join("speakers.json");
77    let path = if candidate.exists() {
78        candidate
79    } else {
80        root.join("speakers.json")
81    };
82    if !path.exists() {
83        return Vec::new();
84    }
85    load_speakers_registry(Some(&path))
86        .into_iter()
87        .map(|(key, entry)| {
88            let label = entry
89                .get("display")
90                .and_then(Value::as_str)
91                .or_else(|| entry.get("full_name").and_then(Value::as_str))
92                .unwrap_or(&key)
93                .to_string();
94            (label, key)
95        })
96        .collect()
97}
98
99fn is_dialogue(e: &Value) -> bool {
100    e.get("type").and_then(Value::as_str) == Some("dialogue")
101}
102
103fn row_of(e: &Value) -> Option<DialogueRow> {
104    if !is_dialogue(e) {
105        return None;
106    }
107    Some(DialogueRow {
108        seq: e.get("seq").and_then(Value::as_i64)?,
109        speaker: e
110            .get("speaker")
111            .and_then(Value::as_str)
112            .unwrap_or("")
113            .to_string(),
114        text: e
115            .get("text")
116            .and_then(Value::as_str)
117            .unwrap_or("")
118            .to_string(),
119    })
120}
121
122/// Dialogue rows matching `q` (a case-insensitive substring of speaker or
123/// text), paginated. `(this page's rows, total matches)`. `(empty, 0)` when
124/// the episode has no parsed file yet.
125pub fn list_dialogue(
126    slug: &str,
127    tag: &str,
128    q: &str,
129    page: i64,
130    per_page: i64,
131) -> Result<(Vec<DialogueRow>, usize), String> {
132    let path = path_for(slug, tag)?;
133    if !path.exists() {
134        return Ok((Vec::new(), 0));
135    }
136    let (_, entries) = load_entries(&path)?;
137    let needle = q.trim().to_lowercase();
138    let mut rows: Vec<DialogueRow> = entries.iter().filter_map(row_of).collect();
139    if !needle.is_empty() {
140        rows.retain(|r| {
141            r.speaker.to_lowercase().contains(&needle) || r.text.to_lowercase().contains(&needle)
142        });
143    }
144    let total = rows.len();
145    let per_page = per_page.max(1) as usize;
146    let start = (page.max(0) as usize) * per_page;
147    let page_rows = rows.into_iter().skip(start).take(per_page).collect();
148    Ok((page_rows, total))
149}
150
151/// One dialogue entry's current speaker/text, or why not (unknown seq, or
152/// the seq is not a dialogue line).
153pub fn get_entry(slug: &str, tag: &str, seq: i64) -> Result<DialogueRow, String> {
154    let path = path_for(slug, tag)?;
155    let (_, entries) = load_entries(&path)?;
156    let entry = entries
157        .iter()
158        .find(|e| e.get("seq").and_then(Value::as_i64) == Some(seq))
159        .ok_or_else(|| format!("No entry with seq {seq}."))?;
160    row_of(entry).ok_or_else(|| {
161        let kind = entry.get("type").and_then(Value::as_str).unwrap_or("?");
162        format!("seq {seq} is a {kind} line, not dialogue.")
163    })
164}
165
166/// Validate, mutate, recompute `stats`, back up and write — the one save
167/// both the dashboard's Dialogue tab and the Timeline's dialogue modal call.
168///
169/// No locking between the two surfaces or the Raw JSON textarea editor:
170/// last write wins, the same as the existing whole-file editor already
171/// behaves — not worth new machinery for a single-operator local tool.
172pub fn save_entry(
173    slug: &str,
174    tag: &str,
175    seq: i64,
176    speaker: &str,
177    text: &str,
178) -> Result<DialogueRow, String> {
179    let path = path_for(slug, tag)?;
180    let (mut top, mut entries) = load_entries(&path)?;
181
182    let idx = entries
183        .iter()
184        .position(|e| e.get("seq").and_then(Value::as_i64) == Some(seq))
185        .ok_or_else(|| format!("No entry with seq {seq}."))?;
186    if !is_dialogue(&entries[idx]) {
187        let kind = entries[idx]
188            .get("type")
189            .and_then(Value::as_str)
190            .unwrap_or("?");
191        return Err(format!(
192            "seq {seq} is a {kind} line, not dialogue — use the Raw JSON tab."
193        ));
194    }
195
196    let text = nfc_normalize(text.trim());
197    if text.is_empty() {
198        return Err("Dialogue text cannot be empty.".into());
199    }
200    let speaker = speaker.trim();
201    if speaker.is_empty() {
202        return Err("Speaker is required.".into());
203    }
204    let known = speaker_choices(slug);
205    if !known.is_empty() && !known.iter().any(|(_, key)| key == speaker) {
206        return Err(format!("Unknown speaker key {speaker:?}."));
207    }
208
209    {
210        let obj = entries[idx]
211            .as_object_mut()
212            .ok_or("entry is not an object")?;
213        obj.insert("speaker".into(), Value::String(speaker.to_string()));
214        obj.insert("text".into(), Value::String(text.clone()));
215    }
216    top.insert(
217        "stats".into(),
218        Value::Object(stats_from_entries_json(&entries)),
219    );
220    top.insert("entries".into(), Value::Array(entries));
221
222    // A failed backup aborts the save: never overwrite what we could not copy.
223    if path.exists() {
224        fs::copy(&path, backup_path(&path))
225            .map_err(|e| format!("Backup failed — not saved: {e}"))?;
226    }
227    fs::write(&path, dumps(&Value::Object(top), Style::INDENT2_UTF8)).map_err(|e| e.to_string())?;
228
229    if let Err(e) = append_dialogue_edit(&path, seq, speaker, &text) {
230        activity::log(&format!("[WARN] dialogue edit journal write failed: {e}"));
231    }
232    activity::log(&format!(
233        "SAVE parsed dialogue → {} seq={seq}",
234        path.display()
235    ));
236
237    Ok(DialogueRow {
238        seq,
239        speaker: speaker.to_string(),
240        text,
241    })
242}
243
244/// The most recent value per key, comparing later entries as they arrive.
245/// `ts`/`generated_at` are both `%Y-%m-%dT%H:%M:%S+00:00`, fixed-width and
246/// always UTC, so plain string comparison sorts them chronologically —
247/// no need to parse them as dates.
248fn keep_latest(map: &mut HashMap<i64, String>, key: i64, value: &str) {
249    map.entry(key)
250        .and_modify(|cur| {
251            if value > cur.as_str() {
252                *cur = value.to_string();
253            }
254        })
255        .or_insert_with(|| value.to_string());
256}
257
258/// Sequence numbers whose dialogue was edited after that seq's stem was
259/// last produced — "changed since last produce". Empty when nothing is
260/// pending, the episode has never been produced (no stem manifest yet), or
261/// no dialogue journal exists.
262///
263/// Self-clearing by construction: once `xil produce --seq-list <seq>
264/// --force` reruns, that seq's manifest `generated_at` moves forward again
265/// and it drops off this list on its own — nothing here is written or
266/// cleared by hand, it is recomputed fresh from the journal and the
267/// manifest every time.
268pub fn pending_reprocess(slug: &str, tag: &str) -> Result<Vec<i64>, String> {
269    let parsed_path = path_for(slug, tag)?;
270    let journal_path = dialogue_edits_path(&parsed_path);
271    if !journal_path.exists() {
272        return Ok(Vec::new());
273    }
274
275    let mut edited_at: HashMap<i64, String> = HashMap::new();
276    let journal_text = fs::read_to_string(&journal_path).map_err(|e| e.to_string())?;
277    for line in journal_text.lines() {
278        let Ok(record) = serde_json::from_str::<Value>(line.trim()) else {
279            continue;
280        };
281        let (Some(seq), Some(ts)) = (
282            record.get("seq").and_then(Value::as_i64),
283            record.get("ts").and_then(Value::as_str),
284        ) else {
285            continue;
286        };
287        keep_latest(&mut edited_at, seq, ts);
288    }
289    if edited_at.is_empty() {
290        return Ok(Vec::new());
291    }
292
293    // The stem manifest's path convention, mirrored from `xil produce`
294    // (`crates/xil-cli/src/cmd/produce.rs::manifest_path`) — read-only here.
295    let stems_dir = derive_paths(slug, tag)["stems"].clone();
296    let manifest_path = stems_dir.join(format!("{}_stem_manifest.json", basename(&stems_dir)));
297    let manifest_entries: Vec<Value> = fs::read_to_string(&manifest_path)
298        .ok()
299        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
300        .and_then(|v| v.get("entries").and_then(Value::as_array).cloned())
301        .unwrap_or_default();
302    if manifest_entries.is_empty() {
303        return Ok(Vec::new());
304    }
305    let mut produced_at: HashMap<i64, String> = HashMap::new();
306    for e in &manifest_entries {
307        let (Some(seq), Some(at)) = (
308            e.get("seq_at_generation").and_then(Value::as_i64),
309            e.get("generated_at").and_then(Value::as_str),
310        ) else {
311            continue;
312        };
313        keep_latest(&mut produced_at, seq, at);
314    }
315
316    let mut pending: Vec<i64> = edited_at
317        .into_iter()
318        .filter(|(seq, edited)| {
319            produced_at
320                .get(seq)
321                .is_some_and(|produced| edited.as_str() > produced.as_str())
322        })
323        .map(|(seq, _)| seq)
324        .collect();
325    pending.sort_unstable();
326    Ok(pending)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    // `path_for`/`load_entries`/etc. all resolve against `XIL_PROJECTROOT`,
334    // which every test in the process shares — exercising them needs the
335    // `Workspace` harness's env-var mutex, so those cases live in
336    // `tests/parsed_entries.rs` (an integration test, one process-wide env
337    // var at a time) rather than here. `backup_path` alone touches no global
338    // state and is worth covering directly, matching `parsed.rs`'s own
339    // `backup_sits_beside_the_file` test.
340    #[test]
341    fn backup_sits_beside_the_file() {
342        assert_eq!(
343            backup_path(Path::new("/ws/parsed/the413/parsed_S01E01.json")),
344            PathBuf::from("/ws/parsed/the413/parsed_S01E01.json.bak")
345        );
346    }
347
348    #[test]
349    fn path_for_refuses_unsafe_slug_or_tag() {
350        assert!(path_for("../etc", "S01E01").is_err());
351        assert!(path_for("the413", "a/b").is_err());
352    }
353}