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::{KnowledgeConfig, Schedule, DIR_USER_ROOT};
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        // TODO: disallow split for read/watch
89        true
90    }
91
92    // -----------------------------------------------------------------------
93    // Move-to commands
94    // -----------------------------------------------------------------------
95
96    /// Add a move-to command. Silently succeeds if the command already exists.
97    pub fn add_move_to_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
98        let mut cfg = self.read_config()?;
99        if cfg.move_to_commands.iter().any(|c| c == cmd) {
100            return Ok(());
101        }
102        cfg.move_to_commands.push(cmd.to_string());
103        self.write_config(&cfg)
104    }
105
106    /// Get all move-to commands.
107    pub fn move_to_cmds(&self) -> Result<Vec<String>, ScheduleError> {
108        let cfg = self.read_config()?;
109        Ok(cfg.move_to_commands)
110    }
111
112    /// Delete a move-to command.
113    pub fn del_move_to_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
114        let mut cfg = self.read_config()?;
115        cfg.move_to_commands.retain(|c| c != cmd);
116        self.write_config(&cfg)
117    }
118
119    // -----------------------------------------------------------------------
120    // Quick commands
121    // -----------------------------------------------------------------------
122
123    /// Add a quick command. Silently succeeds if the command already exists.
124    pub fn add_quick_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
125        let mut cfg = self.read_config()?;
126        if cfg.quick_commands.iter().any(|c| c == cmd) {
127            return Ok(());
128        }
129        cfg.quick_commands.push(cmd.to_string());
130        self.write_config(&cfg)
131    }
132
133    /// Get all quick commands.
134    pub fn quick_cmds(&self) -> Result<Vec<String>, ScheduleError> {
135        let cfg = self.read_config()?;
136        Ok(cfg.quick_commands)
137    }
138
139    /// Delete a quick command.
140    pub fn del_quick_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
141        let mut cfg = self.read_config()?;
142        cfg.quick_commands.retain(|c| c != cmd);
143        self.write_config(&cfg)
144    }
145
146    fn read_config(&self) -> Result<KnowledgeConfig, ScheduleError> {
147        if !self
148            .fs
149            .exists(DIR_USER_ROOT, self.config_filename)
150            .map_err(|e| ScheduleError::Read(e.to_string()))?
151        {
152            return Ok(KnowledgeConfig::default());
153        }
154        let content = self
155            .fs
156            .read(DIR_USER_ROOT, self.config_filename)
157            .map_err(|e| ScheduleError::Read(e.to_string()))?;
158        serde_json::from_str(&content).map_err(|e| ScheduleError::Read(e.to_string()))
159    }
160
161    fn write_config(&self, cfg: &KnowledgeConfig) -> Result<(), ScheduleError> {
162        let json =
163            serde_json::to_string_pretty(cfg).map_err(|e| ScheduleError::Write(e.to_string()))?;
164        self.fs
165            .write(DIR_USER_ROOT, self.config_filename, &json)
166            .map_err(|e| ScheduleError::Write(e.to_string()))
167    }
168}
169
170/// Format a schedule date for display.
171pub fn format_schedule_date(scheduled_at: i64, timezone: FixedOffset) -> String {
172    let now = Utc::now().timestamp();
173    let today_start = beginning_of_day(now);
174    let task_start = beginning_of_day(scheduled_at);
175    let diff_days = (task_start - today_start) / 86400;
176
177    let tz_dt = Utc
178        .timestamp_opt(scheduled_at, 0)
179        .unwrap()
180        .with_timezone(&timezone);
181
182    match diff_days {
183        0 => "Today".to_string(),
184        1 => "Tomorrow".to_string(),
185        2..=6 => format!("{} {:02}", tz_dt.format("%A"), tz_dt.day()),
186        7..=13 => format!("Next {}", tz_dt.format("%A %d")),
187        _ => format!(
188            "{} {}, {}",
189            tz_dt.format("%d %B"),
190            tz_dt.weekday(),
191            tz_dt.year()
192        ),
193    }
194}
195
196/// Calculate the beginning of a day (midnight) as a Unix timestamp.
197pub fn beginning_of_day(timestamp: i64) -> i64 {
198    let dt = Utc.timestamp_opt(timestamp, 0).unwrap();
199    let date = dt.date_naive();
200    date.and_hms_milli_opt(0, 0, 0, 0)
201        .unwrap()
202        .and_utc()
203        .timestamp()
204}
205
206/// Calculate tomorrow's midnight timestamp.
207pub fn tomorrow_timestamp() -> i64 {
208    let tomorrow = Utc::now().date_naive() + chrono::Duration::days(1);
209    tomorrow
210        .and_hms_milli_opt(0, 0, 0, 0)
211        .unwrap()
212        .and_utc()
213        .timestamp()
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use tempfile::TempDir;
220
221    fn test_fs() -> (VirtualFs, TempDir) {
222        let dir = TempDir::new().unwrap();
223        let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
224        (fs, dir)
225    }
226
227    #[test]
228    fn test_add_and_list() {
229        let (fs, _t) = test_fs();
230        let mgr = ScheduleManager::new(&fs, "config.json");
231        mgr.add("Task.md", 1000000, "").unwrap();
232        mgr.add("Other.md", 2000000, "9:00").unwrap();
233        let schedules = mgr.schedules().unwrap();
234        assert_eq!(schedules.len(), 2);
235    }
236
237    #[test]
238    fn test_update_existing() {
239        let (fs, _t) = test_fs();
240        let mgr = ScheduleManager::new(&fs, "config.json");
241        mgr.add("Task.md", 1000000, "").unwrap();
242        mgr.add("Task.md", 2000000, "10:00").unwrap();
243        let schedules = mgr.schedules().unwrap();
244        assert_eq!(schedules.len(), 1);
245        assert_eq!(schedules[0].scheduled_at, 2000000);
246    }
247
248    #[test]
249    fn test_delete() {
250        let (fs, _t) = test_fs();
251        let mgr = ScheduleManager::new(&fs, "config.json");
252        mgr.add("Task.md", 1000000, "").unwrap();
253        mgr.delete("Task.md").unwrap();
254        assert!(mgr.schedules().unwrap().is_empty());
255    }
256
257    #[test]
258    fn test_format_date() {
259        let tz = FixedOffset::east_opt(0).unwrap();
260        let ts = Utc::now().timestamp() + 86400;
261        let formatted = format_schedule_date(ts, tz);
262        assert_eq!(formatted, "Tomorrow");
263    }
264
265    #[test]
266    fn test_tomorrow() {
267        assert!(tomorrow_timestamp() > Utc::now().timestamp());
268    }
269
270    // -----------------------------------------------------------------------
271    // create_default_if_not_exists
272    // -----------------------------------------------------------------------
273
274    #[test]
275    fn test_create_default_if_not_exists_creates() {
276        let (fs, _t) = test_fs();
277        let mgr = ScheduleManager::new(&fs, "config.json");
278        assert!(!fs.exists(DIR_USER_ROOT, "config.json").unwrap());
279        mgr.create_default_if_not_exists().unwrap();
280        assert!(fs.exists(DIR_USER_ROOT, "config.json").unwrap());
281        let cfg: KnowledgeConfig =
282            serde_json::from_str(&fs.read(DIR_USER_ROOT, "config.json").unwrap()).unwrap();
283        assert_eq!(cfg.language, "en");
284        assert!(cfg.schedules.is_empty());
285        assert!(cfg.move_to_commands.is_empty());
286        assert!(cfg.quick_commands.is_empty());
287    }
288
289    #[test]
290    fn test_create_default_if_not_exists_idempotent() {
291        let (fs, _t) = test_fs();
292        let mgr = ScheduleManager::new(&fs, "config.json");
293        mgr.create_default_if_not_exists().unwrap();
294        // Add a schedule so we can verify the file is *not* overwritten.
295        mgr.add("Task.md", 1000, "").unwrap();
296        mgr.create_default_if_not_exists().unwrap();
297        assert_eq!(mgr.schedules().unwrap().len(), 1);
298    }
299
300    // -----------------------------------------------------------------------
301    // should_split_checklist
302    // -----------------------------------------------------------------------
303
304    #[test]
305    fn test_should_split_checklist() {
306        let (fs, _t) = test_fs();
307        let mgr = ScheduleManager::new(&fs, "config.json");
308        assert!(mgr.should_split_checklist("- item1\n- item2"));
309        assert!(mgr.should_split_checklist("anything"));
310    }
311
312    // -----------------------------------------------------------------------
313    // move-to commands
314    // -----------------------------------------------------------------------
315
316    #[test]
317    fn test_add_move_to_cmd() {
318        let (fs, _t) = test_fs();
319        let mgr = ScheduleManager::new(&fs, "config.json");
320        assert!(mgr.move_to_cmds().unwrap().is_empty());
321        mgr.add_move_to_cmd("Archive").unwrap();
322        mgr.add_move_to_cmd("Later").unwrap();
323        assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Archive", "Later"]);
324    }
325
326    #[test]
327    fn test_add_move_to_cmd_duplicate() {
328        let (fs, _t) = test_fs();
329        let mgr = ScheduleManager::new(&fs, "config.json");
330        mgr.add_move_to_cmd("Archive").unwrap();
331        mgr.add_move_to_cmd("Archive").unwrap();
332        assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Archive"]);
333    }
334
335    #[test]
336    fn test_del_move_to_cmd() {
337        let (fs, _t) = test_fs();
338        let mgr = ScheduleManager::new(&fs, "config.json");
339        mgr.add_move_to_cmd("Archive").unwrap();
340        mgr.add_move_to_cmd("Later").unwrap();
341        mgr.del_move_to_cmd("Archive").unwrap();
342        assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Later"]);
343    }
344
345    // -----------------------------------------------------------------------
346    // quick commands
347    // -----------------------------------------------------------------------
348
349    #[test]
350    fn test_add_quick_cmd() {
351        let (fs, _t) = test_fs();
352        let mgr = ScheduleManager::new(&fs, "config.json");
353        assert!(mgr.quick_cmds().unwrap().is_empty());
354        mgr.add_quick_cmd("/done").unwrap();
355        mgr.add_quick_cmd("/shop").unwrap();
356        assert_eq!(mgr.quick_cmds().unwrap(), vec!["/done", "/shop"]);
357    }
358
359    #[test]
360    fn test_add_quick_cmd_duplicate() {
361        let (fs, _t) = test_fs();
362        let mgr = ScheduleManager::new(&fs, "config.json");
363        mgr.add_quick_cmd("/done").unwrap();
364        mgr.add_quick_cmd("/done").unwrap();
365        assert_eq!(mgr.quick_cmds().unwrap(), vec!["/done"]);
366    }
367
368    #[test]
369    fn test_del_quick_cmd() {
370        let (fs, _t) = test_fs();
371        let mgr = ScheduleManager::new(&fs, "config.json");
372        mgr.add_quick_cmd("/done").unwrap();
373        mgr.add_quick_cmd("/shop").unwrap();
374        mgr.del_quick_cmd("/done").unwrap();
375        assert_eq!(mgr.quick_cmds().unwrap(), vec!["/shop"]);
376    }
377}