Skip to main content

fur_cli/renderer/
utils.rs

1use chrono::{DateTime, FixedOffset, Local};
2use serde_json::Value;
3use std::fs;
4use std::path::Path;
5
6use crate::avatars::resolve_avatar;
7
8#[allow(dead_code)]
9pub struct MessageInfo {
10    pub date_str: String,
11    pub time_str: String,
12    pub name: String,
13    pub emoji: String,
14    pub text: String,
15    pub markdown: Option<String>,
16    pub attachment: Option<String>,
17    #[allow(dead_code)]
18    pub children: Vec<String>,
19    pub branches: Vec<Vec<String>>,
20}
21
22/// One-line preview of a message, for trees and status views.
23///
24/// viceroy: previews used to fall back to the *path* of an attachment, so a
25/// long-form message rendered as `chats/slug/CHAT-2026...` instead of its
26/// content. The linked document's first meaningful line is used now.
27pub fn preview_of(msg: &Value, width: usize) -> String {
28    if let Some(text) = msg.get("text").and_then(|v| v.as_str()) {
29        if !text.trim().is_empty() {
30            return clip(text, width);
31        }
32    }
33
34    if let Some(path) = msg.get("markdown").and_then(|v| v.as_str()) {
35        if let Some(line) = first_meaningful_line(path) {
36            return clip(&line, width);
37        }
38
39        // Nothing readable — name the file rather than printing a whole path.
40        let name = Path::new(path)
41            .file_name()
42            .and_then(|f| f.to_str())
43            .unwrap_or(path);
44
45        return format!("📄 {}", clip(name, width.saturating_sub(2)));
46    }
47
48    "<no content>".to_string()
49}
50
51/// Skip front matter, comment markers and heading punctuation to find
52/// something worth showing.
53fn first_meaningful_line(path: &str) -> Option<String> {
54    let content = fs::read_to_string(path).ok()?;
55
56    let mut lines = content.lines().peekable();
57
58    if lines.peek().map(|l| l.trim_end()) == Some("---") {
59        lines.next();
60        for line in lines.by_ref() {
61            if line.trim_end() == "---" {
62                break;
63            }
64        }
65    }
66
67    for line in lines {
68        let trimmed = line.trim();
69
70        if trimmed.is_empty() || trimmed.starts_with("<!--") {
71            continue;
72        }
73
74        let cleaned = trimmed.trim_start_matches('#').trim();
75
76        if !cleaned.is_empty() {
77            return Some(cleaned.to_string());
78        }
79    }
80
81    None
82}
83
84fn clip(text: &str, width: usize) -> String {
85    let line = text.lines().next().unwrap_or("").trim();
86
87    if line.chars().count() <= width {
88        return line.to_string();
89    }
90
91    let kept: String = line.chars().take(width.saturating_sub(1)).collect();
92
93    format!("{}…", kept.trim_end())
94}
95
96pub fn load_message(fur_dir: &Path, msg_id: &str, avatars: &Value) -> Option<MessageInfo> {
97    let msg_path = fur_dir.join("messages").join(format!("{}.json", msg_id));
98    let msg_content = fs::read_to_string(&msg_path).ok()?;
99    let msg_json: Value = serde_json::from_str(&msg_content).ok()?;
100
101    // Timestamp
102    let raw_time = msg_json["timestamp"].as_str().unwrap_or("???");
103    let (date_str, time_str) = if let Ok(dt) = raw_time.parse::<DateTime<FixedOffset>>() {
104        let local_dt = dt.with_timezone(&Local);
105        (
106            local_dt.format("%Y-%m-%d").to_string(),
107            local_dt.format("%H:%M:%S").to_string(),
108        )
109    } else {
110        (raw_time.to_string(), "".to_string())
111    };
112
113    // Avatar
114    let avatar_key = msg_json["avatar"].as_str().unwrap_or("???");
115    let (name, emoji) = resolve_avatar(avatars, avatar_key);
116
117    // Text & markdown
118    let text = msg_json["text"]
119        .as_str()
120        .unwrap_or("<no content>")
121        .to_string();
122    let markdown = msg_json["markdown"].as_str().map(|s| s.to_string());
123
124    // Children
125    let children = msg_json["children"]
126        .as_array()
127        .map(|arr| {
128            arr.iter()
129                .filter_map(|v| v.as_str().map(|s| s.to_string()))
130                .collect()
131        })
132        .unwrap_or_else(Vec::new);
133
134    // Branches
135    let branches = msg_json["branches"]
136        .as_array()
137        .map(|outer| {
138            outer
139                .iter()
140                .filter_map(|block| {
141                    block.as_array().map(|arr| {
142                        arr.iter()
143                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
144                            .collect::<Vec<_>>()
145                    })
146                })
147                .collect::<Vec<_>>()
148        })
149        .unwrap_or_else(Vec::new);
150
151    let attachment = msg_json["attachment"].as_str().map(|s| s.to_string());
152
153    Some(MessageInfo {
154        date_str,
155        time_str,
156        name,
157        emoji,
158        text,
159        markdown,
160        attachment,
161        children,
162        branches,
163    })
164}