fur_cli/commands/
message.rs1use clap::Parser;
2use serde_json::{Value, json};
3use std::fs;
4use std::io::{Write};
5use std::path::Path;
6
7#[derive(Parser, Debug)]
9pub struct MsgArgs {
10 #[arg(index = 1)]
12 pub id_prefix: Option<String>,
13
14 #[arg(index = 2)]
16 pub text_value: Option<String>,
17
18 #[arg(long)]
19 pub edit: bool,
20
21 #[arg(long, alias="rem")]
22 pub delete: bool,
23
24 #[arg(long, alias="file")]
25 pub file: Option<String>,
26
27 #[arg(long)]
28 pub avatar: Option<String>,
29
30 #[arg(long)]
31 pub interactive: bool,
32}
33
34pub fn run_msg(args: MsgArgs) {
36 if args.delete {
37 return run_delete(args);
38 }
39 run_edit(args);
40}
41
42fn run_delete(args: MsgArgs) {
49 let target = detect_id(&args.id_prefix)
51 .unwrap_or_else(|| resolve_target_message(None));
52
53 print!("Delete message {}? [y/N]: ", &target[..8]);
54 std::io::stdout().flush().unwrap();
55
56 let mut buf = String::new();
57 std::io::stdin().read_line(&mut buf).unwrap();
58 if !["y", "Y", "yes", "YES"].contains(&buf.trim()) {
59 println!("❌ Cancelled.");
60 return;
61 }
62
63 recursive_delete(&target);
64 remove_from_parent_or_root(&target);
65 update_current_after_delete(&target);
66
67 println!("🗑️ Deleted {}", &target[..8]);
68}
69
70fn run_edit(args: MsgArgs) {
77 let (id_opt, mut new_text) =
78 classify_id_and_text(args.id_prefix, args.text_value);
79
80 let mid = id_opt.unwrap_or_else(|| resolve_target_message(None));
82
83 let fur = Path::new(".fur");
84 let msg_path = fur.join("messages").join(format!("{}.json", mid));
85
86 let mut msg: Value =
87 serde_json::from_str(&fs::read_to_string(&msg_path).unwrap()).unwrap();
88
89 if args.interactive {
91 let edited = run_interactive_editor(
92 msg["text"].as_str().unwrap_or_default()
93 );
94 new_text = Some(edited);
95 }
96
97 if let Some(t) = new_text {
99 msg["text"] = json!(t);
100 msg["markdown"] = json!(null);
101 }
102
103 if let Some(fpath) = args.file {
105 msg["markdown"] = json!(fpath);
106 msg["text"] = json!(null);
107 }
108
109 if let Some(a) = args.avatar {
111 msg["avatar"] = json!(a);
112 }
113
114 write_json(&msg_path, &msg);
115
116 println!("✏️ Edited {}", &mid[..8]);
117}
118
119fn detect_id(x: &Option<String>) -> Option<String> {
128 let Some(val) = x else { return None; };
129
130 if val.starts_with("--") {
132 return None;
133 }
134
135 if let Some(id) = resolve_prefix_if_exists(val) {
137 return Some(id);
138 }
139
140 None
141}
142
143fn classify_id_and_text(
150 id_prefix: Option<String>,
151 text_value: Option<String>
152) -> (Option<String>, Option<String>) {
153
154 if id_prefix.is_some() {
156 if let Some(real_id) = detect_id(&id_prefix) {
157 return (Some(real_id), text_value);
158 }
159 }
160
161 if let Some(val) = id_prefix {
163 return (None, Some(val));
164 }
165
166 if let Some(val) = text_value {
168 return (None, Some(val));
169 }
170
171 (None, None)
172}
173
174
175fn resolve_prefix_if_exists(pfx: &str) -> Option<String> {
177 let fur = Path::new(".fur");
178 let (_index, tid) = resolve_active_conversation();
179
180 let convo_path = fur.join("threads").join(format!("{}.json", tid));
181 let convo: Value =
182 serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
183
184 let root = convo["messages"]
185 .as_array()
186 .unwrap_or(&vec![])
187 .iter()
188 .filter_map(|x| x.as_str().map(|s| s.to_string()))
189 .collect::<Vec<String>>();
190
191 let matches: Vec<&String> =
192 root.iter().filter(|id| id.starts_with(pfx)).collect();
193
194 if matches.len() == 1 {
195 Some(matches[0].clone())
196 } else {
197 None
198 }
199}
200
201fn resolve_active_conversation() -> (Value, String) {
208 let idx_path = Path::new(".fur/index.json");
209 let index: Value =
210 serde_json::from_str(&fs::read_to_string(idx_path).unwrap()).unwrap();
211 let tid = index["active_thread"].as_str().unwrap_or("").to_string();
212 (index, tid)
213}
214
215fn resolve_target_message(prefix: Option<String>) -> String {
216 let fur = Path::new(".fur");
217
218 let (index, tid) = resolve_active_conversation();
219 let convo_path = fur.join("threads").join(format!("{}.json", tid));
220 let convo: Value =
221 serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
222
223 let root = convo["messages"]
224 .as_array()
225 .unwrap_or(&vec![])
226 .iter()
227 .filter_map(|v| v.as_str().map(|s| s.to_string()))
228 .collect::<Vec<String>>();
229
230 if let Some(p) = prefix {
231 return resolve_prefix(&root, &p);
232 }
233
234 if let Some(cur) = index["current_message"].as_str() {
236 if !cur.is_empty() {
237 return cur.to_string();
238 }
239 }
240
241 root.last().expect("❌ No messages").to_string()
243}
244
245fn resolve_prefix(root_ids: &Vec<String>, prefix: &str) -> String {
246 let matches: Vec<&String> =
247 root_ids.iter().filter(|id| id.starts_with(prefix)).collect();
248
249 if matches.is_empty() {
250 eprintln!("❌ No message matches '{}'", prefix);
251 std::process::exit(1);
252 }
253 if matches.len() > 1 {
254 eprintln!("❌ Ambiguous '{}': {:?}", prefix, matches);
255 std::process::exit(1);
256 }
257
258 matches[0].to_string()
259}
260
261fn recursive_delete(mid: &str) {
268 let fur = Path::new(".fur");
269 let msg_path = fur.join("messages").join(format!("{}.json", mid));
270
271 let content = match fs::read_to_string(&msg_path) {
272 Ok(c) => c,
273 Err(_) => return,
274 };
275
276 let msg: Value = match serde_json::from_str(&content) {
277 Ok(v) => v,
278 Err(_) => return,
279 };
280
281 if let Some(children) = msg["children"].as_array() {
282 for c in children {
283 if let Some(cid) = c.as_str() {
284 recursive_delete(cid);
285 }
286 }
287 }
288
289 let _ = fs::remove_file(&msg_path);
290}
291
292fn remove_from_parent_or_root(mid: &str) {
293 let fur = Path::new(".fur");
294
295 let msg_path = fur.join("messages").join(format!("{}.json", mid));
296 let raw = fs::read_to_string(&msg_path).unwrap_or("{}".into());
297 let msg: Value = serde_json::from_str(&raw).unwrap_or(json!({}));
298
299 if let Some(pid) = msg["parent"].as_str() {
301 let ppath = fur.join("messages").join(format!("{}.json", pid));
302 if let Ok(content) = fs::read_to_string(&ppath) {
303 let mut parent: Value = serde_json::from_str(&content).unwrap();
304 if let Some(arr) = parent["children"].as_array_mut() {
305 arr.retain(|v| v.as_str() != Some(mid));
306 }
307 write_json(&ppath, &parent);
308 }
309 return;
310 }
311
312 let (_index, tid) = resolve_active_conversation();
314 let convo_path = fur.join("threads").join(format!("{}.json", tid));
315 let mut convo: Value =
316 serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
317
318 if let Some(arr) = convo["messages"].as_array_mut() {
319 arr.retain(|v| v.as_str() != Some(mid));
320 }
321
322 write_json(&convo_path, &convo);
323}
324
325fn update_current_after_delete(mid: &str) {
326 let fur = Path::new(".fur");
327 let idx_path = fur.join("index.json");
328 let mut index: Value =
329 serde_json::from_str(&fs::read_to_string(&idx_path).unwrap()).unwrap();
330
331 if let Some(cur) = index["current_message"].as_str() {
332 if cur == mid {
333 index["current_message"] = json!(null);
334 }
335 }
336
337 write_json(&idx_path, &index);
338}
339
340fn run_interactive_editor(initial: &str) -> String {
347 use std::process::Command;
348 use std::env;
349
350 let tmp = "/tmp/fur_edit_msg.txt";
351 fs::write(tmp, initial).unwrap();
352
353 let editor = env::var("EDITOR").unwrap_or("nano".into());
354
355 Command::new(editor)
356 .arg(tmp)
357 .status()
358 .expect("❌ Could not start editor");
359
360 fs::read_to_string(tmp).unwrap()
361}
362
363fn write_json(path: &Path, v: &Value) {
364 fs::write(path, serde_json::to_string_pretty(v).unwrap()).unwrap();
365}