Skip to main content

oxios_markdown/
plugins.rs

1//! World clock plugin — generate time reports for multiple timezones.
2//!
3//! Ported from files.md (`server/plugins/world_clock.go`) by Artem Zakirullin.
4
5use chrono::{TimeZone, Utc};
6use chrono_tz::Tz;
7
8/// A timezone entry in the world clock report.
9#[derive(Debug, Clone)]
10pub struct TimezoneEntry {
11    /// Display name (e.g., "MSK").
12    pub name: String,
13    /// Icon for this timezone.
14    pub icon: String,
15    /// Formatted current/converted time.
16    pub current_time: String,
17}
18
19/// Built-in timezone definitions: (name, icon, IANA timezone).
20const TIMEZONES: &[(&str, &str, &str)] = &[
21    ("UTC", "🕰", "UTC"),
22    ("MSK", "🔺", "Europe/Moscow"),
23    ("CY", "🏝", "Asia/Nicosia"),
24    ("ME", "⛰", "Europe/Podgorica"),
25];
26
27/// Default timezone names (in order).
28pub fn default_timezone_names() -> Vec<&'static str> {
29    TIMEZONES.iter().map(|(name, _, _)| *name).collect()
30}
31
32/// Generate world clock report for the current moment.
33pub fn world_clock_now() -> Vec<TimezoneEntry> {
34    world_clock_for_names(&default_timezone_names())
35}
36
37/// Generate world clock report for given timezone names.
38pub fn world_clock_for_names(timezone_names: &[&str]) -> Vec<TimezoneEntry> {
39    let now = Utc::now();
40    timezone_names
41        .iter()
42        .filter_map(|name| {
43            let (icon, tz_str) = find_tz(name)?;
44            let time = format_time(&now, tz_str);
45            Some(TimezoneEntry {
46                name: name.to_string(),
47                icon: icon.to_string(),
48                current_time: time,
49            })
50        })
51        .collect()
52}
53
54/// Try to parse a message as a date (DD.MM.YYYY) and show it in all timezones.
55/// Returns None if the message is not a valid date.
56pub fn parse_and_show_date(msg: &str) -> Option<Vec<TimezoneEntry>> {
57    let date = chrono::NaiveDate::parse_from_str(msg.trim(), "%d.%m.%Y").ok()?;
58    let time = date.and_hms_opt(0, 0, 0)?;
59    let utc_dt = Utc.from_utc_datetime(&time);
60    Some(show_timestamp(&utc_dt))
61}
62
63/// Try to parse a message as a datetime (DD.MM.YYYY HH:MM:SS) and show it in all timezones.
64/// Returns None if the message is not a valid datetime.
65pub fn parse_and_show_time(msg: &str) -> Option<Vec<TimezoneEntry>> {
66    let time = chrono::NaiveDateTime::parse_from_str(msg.trim(), "%d.%m.%Y %H:%M:%S").ok()?;
67    let utc_dt = Utc.from_utc_datetime(&time);
68    Some(show_time(&utc_dt))
69}
70
71/// Try to parse a message as a Unix timestamp and show it in all timezones.
72/// Handles seconds (10 digits), milliseconds (13 digits), and microseconds (16 digits).
73/// Returns None if the message is not a valid timestamp.
74pub fn parse_and_show_timestamp(msg: &str) -> Option<Vec<TimezoneEntry>> {
75    let ts: i64 = msg.trim().parse().ok()?;
76    if ts <= 999_999 {
77        return None;
78    }
79    let utc_dt = if ts > 9_999_999_999_999 {
80        // microseconds
81        chrono::DateTime::from_timestamp_micros(ts)?
82    } else if ts > 9_999_999_999 {
83        // milliseconds
84        chrono::DateTime::from_timestamp_millis(ts)?
85    } else {
86        // seconds
87        Utc.timestamp_opt(ts, 0).single()?
88    };
89    Some(show_time(&utc_dt))
90}
91
92/// Check if the message can be handled by this plugin.
93pub fn can_handle(msg: &str) -> bool {
94    parse_and_show_date(msg).is_some()
95        || parse_and_show_time(msg).is_some()
96        || parse_and_show_timestamp(msg).is_some()
97}
98
99/// Handle a message: try date, time, and timestamp parsing.
100pub fn handle(msg: &str) -> Option<Vec<TimezoneEntry>> {
101    if let Some(entries) = parse_and_show_date(msg) {
102        return Some(entries);
103    }
104    if let Some(entries) = parse_and_show_time(msg) {
105        return Some(entries);
106    }
107    if let Some(entries) = parse_and_show_timestamp(msg) {
108        return Some(entries);
109    }
110    None
111}
112
113/// Format the world clock report as a string.
114pub fn format_report(entries: &[TimezoneEntry]) -> String {
115    entries
116        .iter()
117        .map(|e| format!("{} {} {}", e.icon, e.current_time, e.name))
118        .collect::<Vec<_>>()
119        .join("\n")
120}
121
122// ── Internal helpers ────────────────────────────────────────
123
124fn find_tz(name: &str) -> Option<(&'static str, &'static str)> {
125    TIMEZONES
126        .iter()
127        .find(|(n, _, _)| *n == name)
128        .map(|(_, icon, tz)| (*icon, *tz))
129}
130
131fn format_time(utc_dt: &chrono::DateTime<Utc>, tz_str: &str) -> String {
132    if tz_str == "UTC" {
133        utc_dt.format("%d.%m.%Y %H:%M:%S").to_string()
134    } else if let Ok(tz) = tz_str.parse::<Tz>() {
135        let local = utc_dt.with_timezone(&tz);
136        local.format("%d.%m.%Y %H:%M:%S").to_string()
137    } else {
138        // Fallback: try FixedOffset
139        utc_dt.format("%d.%m.%Y %H:%M:%S").to_string()
140    }
141}
142
143fn show_timestamp(utc_dt: &chrono::DateTime<Utc>) -> Vec<TimezoneEntry> {
144    show_impl(utc_dt, format_time)
145}
146
147fn show_time(utc_dt: &chrono::DateTime<Utc>) -> Vec<TimezoneEntry> {
148    show_impl(utc_dt, format_time)
149}
150
151fn show_impl<F>(utc_dt: &chrono::DateTime<Utc>, formatter: F) -> Vec<TimezoneEntry>
152where
153    F: Fn(&chrono::DateTime<Utc>, &str) -> String,
154{
155    TIMEZONES
156        .iter()
157        .map(|(name, icon, tz_str)| TimezoneEntry {
158            name: name.to_string(),
159            icon: icon.to_string(),
160            current_time: formatter(utc_dt, tz_str),
161        })
162        .collect()
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_world_clock_now() {
171        let entries = world_clock_now();
172        assert!(entries.len() >= 4);
173        assert!(entries.iter().any(|e| e.name == "UTC"));
174        assert!(entries.iter().any(|e| e.name == "MSK"));
175    }
176
177    #[test]
178    fn test_parse_date() {
179        let result = parse_and_show_date("01.06.2024");
180        assert!(result.is_some());
181        let entries = result.unwrap();
182        assert!(entries[0].current_time.contains("01.06.2024"));
183    }
184
185    #[test]
186    fn test_parse_date_invalid() {
187        assert!(parse_and_show_date("not a date").is_none());
188    }
189
190    #[test]
191    fn test_parse_time() {
192        let result = parse_and_show_time("01.06.2024 12:30:45");
193        assert!(result.is_some());
194        let entries = result.unwrap();
195        assert!(entries[0].current_time.contains("12:30:45"));
196    }
197
198    #[test]
199    fn test_parse_timestamp_seconds() {
200        let result = parse_and_show_timestamp("1717237200");
201        assert!(result.is_some());
202    }
203
204    #[test]
205    fn test_parse_timestamp_millis() {
206        let result = parse_and_show_timestamp("1717237200000");
207        assert!(result.is_some());
208    }
209
210    #[test]
211    fn test_parse_timestamp_micros() {
212        let result = parse_and_show_timestamp("1717237200000000");
213        assert!(result.is_some());
214    }
215
216    #[test]
217    fn test_parse_timestamp_invalid() {
218        assert!(parse_and_show_timestamp("123").is_none());
219        assert!(parse_and_show_timestamp("abc").is_none());
220    }
221
222    #[test]
223    fn test_can_handle() {
224        assert!(can_handle("01.06.2024"));
225        assert!(can_handle("01.06.2024 12:30:00"));
226        assert!(can_handle("1717237200"));
227        assert!(!can_handle("hello world"));
228    }
229
230    #[test]
231    fn test_format_report() {
232        let entries = vec![TimezoneEntry {
233            name: "UTC".into(),
234            icon: "🕰".into(),
235            current_time: "01.06.2024 12:00:00".into(),
236        }];
237        let report = format_report(&entries);
238        assert!(report.contains("🕰"));
239        assert!(report.contains("UTC"));
240    }
241}