Skip to main content

oxios_markdown/
schedule.rs

1//! Schedule management for tasks.
2//!
3//! Ported from files.md (`server/schedule/mod.rs`) by Artem Zakirullin.
4//! Manages scheduled tasks stored in the knowledge config.
5
6use chrono::{Datelike, FixedOffset, TimeZone, Utc};
7
8use crate::fs::VirtualFs;
9use crate::types::{DIR_USER_ROOT, KnowledgeConfig, Schedule};
10
11/// Schedule-specific errors.
12#[derive(Debug, thiserror::Error)]
13pub enum ScheduleError {
14    /// Config read failure.
15    #[error("config read: {0}")]
16    Read(String),
17    /// Config write failure.
18    #[error("config write: {0}")]
19    Write(String),
20}
21
22/// Manages scheduled tasks for a knowledge base.
23pub struct ScheduleManager<'a> {
24    fs: &'a VirtualFs,
25    config_filename: &'a str,
26}
27
28impl<'a> ScheduleManager<'a> {
29    /// Create a new schedule manager.
30    pub fn new(fs: &'a VirtualFs, config_filename: &'a str) -> Self {
31        Self {
32            fs,
33            config_filename,
34        }
35    }
36
37    /// Get all schedules.
38    pub fn schedules(&self) -> Result<Vec<Schedule>, ScheduleError> {
39        let cfg = self.read_config()?;
40        Ok(cfg.schedules)
41    }
42
43    /// Add or update a schedule for a filename.
44    pub fn add(&self, filename: &str, scheduled_at: i64, cron: &str) -> Result<(), ScheduleError> {
45        let mut cfg = self.read_config()?;
46        if let Some(s) = cfg.schedules.iter_mut().find(|s| s.filename == filename) {
47            s.scheduled_at = scheduled_at;
48            s.cron = cron.to_string();
49        } else {
50            cfg.schedules.push(Schedule {
51                filename: filename.to_string(),
52                scheduled_at,
53                cron: cron.to_string(),
54                cmd: String::new(),
55            });
56        }
57        self.write_config(&cfg)
58    }
59
60    /// Delete a schedule by filename.
61    pub fn delete(&self, filename: &str) -> Result<(), ScheduleError> {
62        let mut cfg = self.read_config()?;
63        cfg.schedules.retain(|s| s.filename != filename);
64        self.write_config(&cfg)
65    }
66
67    // -----------------------------------------------------------------------
68    // Config-level helpers
69    // -----------------------------------------------------------------------
70
71    /// Create the default config file if it doesn't already exist.
72    pub fn create_default_if_not_exists(&self) -> Result<(), ScheduleError> {
73        if self
74            .fs
75            .exists(DIR_USER_ROOT, self.config_filename)
76            .map_err(|e| ScheduleError::Read(e.to_string()))?
77        {
78            return Ok(());
79        }
80        self.write_config(&KnowledgeConfig::default())
81    }
82
83    /// Check whether a checklist string should be split.
84    ///
85    /// Currently always returns `true` (matches the Go original).
86    /// The `checklist` parameter is kept for future per-list overrides.
87    pub fn should_split_checklist(&self, _checklist: &str) -> bool {
88        // Upstream Go parity; intentionally always true (kept public for
89        // API stability — removing it would need a major bump).
90        true
91    }
92
93    // -----------------------------------------------------------------------
94    // Move-to commands
95    // -----------------------------------------------------------------------
96
97    /// Add a move-to command. Silently succeeds if the command already exists.
98    pub fn add_move_to_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
99        let mut cfg = self.read_config()?;
100        if cfg.move_to_commands.iter().any(|c| c == cmd) {
101            return Ok(());
102        }
103        cfg.move_to_commands.push(cmd.to_string());
104        self.write_config(&cfg)
105    }
106
107    /// Get all move-to commands.
108    pub fn move_to_cmds(&self) -> Result<Vec<String>, ScheduleError> {
109        let cfg = self.read_config()?;
110        Ok(cfg.move_to_commands)
111    }
112
113    /// Delete a move-to command.
114    pub fn del_move_to_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
115        let mut cfg = self.read_config()?;
116        cfg.move_to_commands.retain(|c| c != cmd);
117        self.write_config(&cfg)
118    }
119
120    // -----------------------------------------------------------------------
121    // Quick commands
122    // -----------------------------------------------------------------------
123
124    /// Add a quick command. Silently succeeds if the command already exists.
125    pub fn add_quick_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
126        let mut cfg = self.read_config()?;
127        if cfg.quick_commands.iter().any(|c| c == cmd) {
128            return Ok(());
129        }
130        cfg.quick_commands.push(cmd.to_string());
131        self.write_config(&cfg)
132    }
133
134    /// Get all quick commands.
135    pub fn quick_cmds(&self) -> Result<Vec<String>, ScheduleError> {
136        let cfg = self.read_config()?;
137        Ok(cfg.quick_commands)
138    }
139
140    /// Delete a quick command.
141    pub fn del_quick_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
142        let mut cfg = self.read_config()?;
143        cfg.quick_commands.retain(|c| c != cmd);
144        self.write_config(&cfg)
145    }
146
147    fn read_config(&self) -> Result<KnowledgeConfig, ScheduleError> {
148        if !self
149            .fs
150            .exists(DIR_USER_ROOT, self.config_filename)
151            .map_err(|e| ScheduleError::Read(e.to_string()))?
152        {
153            return Ok(KnowledgeConfig::default());
154        }
155        let content = self
156            .fs
157            .read(DIR_USER_ROOT, self.config_filename)
158            .map_err(|e| ScheduleError::Read(e.to_string()))?;
159        serde_json::from_str(&content).map_err(|e| ScheduleError::Read(e.to_string()))
160    }
161
162    fn write_config(&self, cfg: &KnowledgeConfig) -> Result<(), ScheduleError> {
163        let json =
164            serde_json::to_string_pretty(cfg).map_err(|e| ScheduleError::Write(e.to_string()))?;
165        self.fs
166            .write(DIR_USER_ROOT, self.config_filename, &json)
167            .map_err(|e| ScheduleError::Write(e.to_string()))
168    }
169}
170
171/// Format a schedule date for display.
172pub fn format_schedule_date(scheduled_at: i64, timezone: FixedOffset) -> String {
173    let now = Utc::now().timestamp();
174    let today_start = beginning_of_day(now);
175    let task_start = beginning_of_day(scheduled_at);
176    let diff_days = (task_start - today_start) / 86400;
177
178    let tz_dt = Utc
179        .timestamp_opt(scheduled_at, 0)
180        .single()
181        .expect("valid Unix timestamp")
182        .with_timezone(&timezone);
183
184    match diff_days {
185        0 => "Today".to_string(),
186        1 => "Tomorrow".to_string(),
187        2..=6 => format!("{} {:02}", tz_dt.format("%A"), tz_dt.day()),
188        7..=13 => format!("Next {}", tz_dt.format("%A %d")),
189        _ => format!(
190            "{} {}, {}",
191            tz_dt.format("%d %B"),
192            tz_dt.weekday(),
193            tz_dt.year()
194        ),
195    }
196}
197
198/// Calculate the beginning of a day (midnight) as a Unix timestamp.
199pub fn beginning_of_day(timestamp: i64) -> i64 {
200    let dt = Utc
201        .timestamp_opt(timestamp, 0)
202        .single()
203        .expect("valid Unix timestamp");
204    let date = dt.date_naive();
205    date.and_hms_milli_opt(0, 0, 0, 0)
206        .expect("midnight is always a valid time")
207        .and_utc()
208        .timestamp()
209}
210
211/// Calculate tomorrow's midnight timestamp.
212pub fn tomorrow_timestamp() -> i64 {
213    let tomorrow = Utc::now().date_naive() + chrono::Duration::days(1);
214    tomorrow
215        .and_hms_milli_opt(0, 0, 0, 0)
216        .expect("midnight is always a valid time")
217        .and_utc()
218        .timestamp()
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use tempfile::TempDir;
225
226    fn test_fs() -> (VirtualFs, TempDir) {
227        let dir = TempDir::new().unwrap();
228        let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
229        (fs, dir)
230    }
231
232    #[test]
233    fn test_add_and_list() {
234        let (fs, _t) = test_fs();
235        let mgr = ScheduleManager::new(&fs, "config.json");
236        mgr.add("Task.md", 1000000, "").unwrap();
237        mgr.add("Other.md", 2000000, "9:00").unwrap();
238        let schedules = mgr.schedules().unwrap();
239        assert_eq!(schedules.len(), 2);
240    }
241
242    #[test]
243    fn test_update_existing() {
244        let (fs, _t) = test_fs();
245        let mgr = ScheduleManager::new(&fs, "config.json");
246        mgr.add("Task.md", 1000000, "").unwrap();
247        mgr.add("Task.md", 2000000, "10:00").unwrap();
248        let schedules = mgr.schedules().unwrap();
249        assert_eq!(schedules.len(), 1);
250        assert_eq!(schedules[0].scheduled_at, 2000000);
251    }
252
253    #[test]
254    fn test_delete() {
255        let (fs, _t) = test_fs();
256        let mgr = ScheduleManager::new(&fs, "config.json");
257        mgr.add("Task.md", 1000000, "").unwrap();
258        mgr.delete("Task.md").unwrap();
259        assert!(mgr.schedules().unwrap().is_empty());
260    }
261
262    #[test]
263    fn test_format_date() {
264        let tz = FixedOffset::east_opt(0).expect("valid UTC offset");
265        let ts = Utc::now().timestamp() + 86400;
266        let formatted = format_schedule_date(ts, tz);
267        assert_eq!(formatted, "Tomorrow");
268    }
269
270    #[test]
271    fn test_tomorrow() {
272        assert!(tomorrow_timestamp() > Utc::now().timestamp());
273    }
274
275    // -----------------------------------------------------------------------
276    // create_default_if_not_exists
277    // -----------------------------------------------------------------------
278
279    #[test]
280    fn test_create_default_if_not_exists_creates() {
281        let (fs, _t) = test_fs();
282        let mgr = ScheduleManager::new(&fs, "config.json");
283        assert!(!fs.exists(DIR_USER_ROOT, "config.json").unwrap());
284        mgr.create_default_if_not_exists().unwrap();
285        assert!(fs.exists(DIR_USER_ROOT, "config.json").unwrap());
286        let cfg: KnowledgeConfig =
287            serde_json::from_str(&fs.read(DIR_USER_ROOT, "config.json").unwrap()).unwrap();
288        assert_eq!(cfg.language, "en");
289        assert!(cfg.schedules.is_empty());
290        assert!(cfg.move_to_commands.is_empty());
291        assert!(cfg.quick_commands.is_empty());
292    }
293
294    #[test]
295    fn test_create_default_if_not_exists_idempotent() {
296        let (fs, _t) = test_fs();
297        let mgr = ScheduleManager::new(&fs, "config.json");
298        mgr.create_default_if_not_exists().unwrap();
299        // Add a schedule so we can verify the file is *not* overwritten.
300        mgr.add("Task.md", 1000, "").unwrap();
301        mgr.create_default_if_not_exists().unwrap();
302        assert_eq!(mgr.schedules().unwrap().len(), 1);
303    }
304
305    // -----------------------------------------------------------------------
306    // should_split_checklist
307    // -----------------------------------------------------------------------
308
309    #[test]
310    fn test_should_split_checklist() {
311        let (fs, _t) = test_fs();
312        let mgr = ScheduleManager::new(&fs, "config.json");
313        assert!(mgr.should_split_checklist("- item1\n- item2"));
314        assert!(mgr.should_split_checklist("anything"));
315    }
316
317    // -----------------------------------------------------------------------
318    // move-to commands
319    // -----------------------------------------------------------------------
320
321    #[test]
322    fn test_add_move_to_cmd() {
323        let (fs, _t) = test_fs();
324        let mgr = ScheduleManager::new(&fs, "config.json");
325        assert!(mgr.move_to_cmds().unwrap().is_empty());
326        mgr.add_move_to_cmd("Archive").unwrap();
327        mgr.add_move_to_cmd("Later").unwrap();
328        assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Archive", "Later"]);
329    }
330
331    #[test]
332    fn test_add_move_to_cmd_duplicate() {
333        let (fs, _t) = test_fs();
334        let mgr = ScheduleManager::new(&fs, "config.json");
335        mgr.add_move_to_cmd("Archive").unwrap();
336        mgr.add_move_to_cmd("Archive").unwrap();
337        assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Archive"]);
338    }
339
340    #[test]
341    fn test_del_move_to_cmd() {
342        let (fs, _t) = test_fs();
343        let mgr = ScheduleManager::new(&fs, "config.json");
344        mgr.add_move_to_cmd("Archive").unwrap();
345        mgr.add_move_to_cmd("Later").unwrap();
346        mgr.del_move_to_cmd("Archive").unwrap();
347        assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Later"]);
348    }
349
350    // -----------------------------------------------------------------------
351    // quick commands
352    // -----------------------------------------------------------------------
353
354    #[test]
355    fn test_add_quick_cmd() {
356        let (fs, _t) = test_fs();
357        let mgr = ScheduleManager::new(&fs, "config.json");
358        assert!(mgr.quick_cmds().unwrap().is_empty());
359        mgr.add_quick_cmd("/done").unwrap();
360        mgr.add_quick_cmd("/shop").unwrap();
361        assert_eq!(mgr.quick_cmds().unwrap(), vec!["/done", "/shop"]);
362    }
363
364    #[test]
365    fn test_add_quick_cmd_duplicate() {
366        let (fs, _t) = test_fs();
367        let mgr = ScheduleManager::new(&fs, "config.json");
368        mgr.add_quick_cmd("/done").unwrap();
369        mgr.add_quick_cmd("/done").unwrap();
370        assert_eq!(mgr.quick_cmds().unwrap(), vec!["/done"]);
371    }
372
373    #[test]
374    fn test_del_quick_cmd() {
375        let (fs, _t) = test_fs();
376        let mgr = ScheduleManager::new(&fs, "config.json");
377        mgr.add_quick_cmd("/done").unwrap();
378        mgr.add_quick_cmd("/shop").unwrap();
379        mgr.del_quick_cmd("/done").unwrap();
380        assert_eq!(mgr.quick_cmds().unwrap(), vec!["/shop"]);
381    }
382}