1use 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#[derive(Clone, Debug, PartialEq)]
28pub struct DialogueRow {
29 pub seq: i64,
30 pub speaker: String,
31 pub text: String,
32}
33
34pub 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
62fn 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
69pub 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
122pub 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
151pub 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
166pub 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 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
244fn 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
258pub 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 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 #[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}