Skip to main content

oxios_markdown/
worker.rs

1//! Nightly worker — daily cleanup of completed items.
2//!
3//! Ported from files.md (`server/worker.go`) by Artem Zakirullin.
4//! Handles removal of completed checklist items from Chat.md and Later.md,
5//! archiving them to Done.md, and adding journal entries.
6//!
7//! Bot/Telegram dependencies are removed — this module contains pure functions.
8
9use chrono::{Datelike, FixedOffset, TimeZone, Utc};
10use regex::Regex;
11
12use crate::chat::read_chat_msgs;
13use crate::fs::VirtualFs;
14use crate::journal::add_record as journal_add_record;
15use crate::parser::norm_new_lines;
16use crate::types::{
17    CHAT_FILENAME, DIR_ARCHIVE, DIR_USER_ROOT, DONE_FILENAME, FsError, KnowledgeConfig,
18    LATER_FILENAME,
19};
20
21/// Result of a nightly cleanup run.
22#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
23pub struct NightlyReport {
24    /// Number of items archived to Done.md.
25    pub archived_count: usize,
26    /// Number of journal entries added.
27    pub journal_count: usize,
28}
29
30/// Remove completed checklist items from Chat.md and Later.md,
31/// archive them to Done.md, and add journal entries.
32///
33/// This is a pure function that operates on the virtual filesystem.
34/// The `timezone` parameter controls how timestamps are formatted.
35pub fn remove_completed_items(
36    fs: &VirtualFs,
37    config: &KnowledgeConfig,
38) -> Result<NightlyReport, FsError> {
39    let tz = parse_timezone(&config.timezone);
40    let mut report = NightlyReport::default();
41
42    // Targets: (filename, reducer function)
43    // We apply two reducers to Chat.md:
44    //   1. checklist removal (from both Chat and Later)
45    //   2. inbox entry removal (Chat only)
46    type Reducer = fn(&str) -> (String, String);
47
48    let targets: &[(&str, Reducer)] = &[
49        (CHAT_FILENAME, remove_completed_checklist),
50        (LATER_FILENAME, remove_completed_checklist),
51        (CHAT_FILENAME, remove_completed_inbox_entries),
52    ];
53
54    for &(filename, reducer) in targets {
55        let md = match fs.read(DIR_USER_ROOT, filename) {
56            Ok(content) => content,
57            Err(FsError::Io(_)) => continue, // file doesn't exist
58            Err(e) => return Err(e),
59        };
60
61        let (reduced_md, removed_md) = reducer(&md);
62        if removed_md.is_empty() {
63            continue;
64        }
65
66        fs.write(DIR_USER_ROOT, filename, &reduced_md)?;
67
68        // Archive removed items to Done.md
69        let done_md = match fs.read(DIR_ARCHIVE, DONE_FILENAME) {
70            Ok(content) => content,
71            Err(FsError::Io(_)) => String::new(),
72            Err(e) => return Err(e),
73        };
74
75        let now_tz = Utc::now().with_timezone(&tz);
76        let header = format!(
77            "#### {} {}, {}",
78            now_tz.day(),
79            now_tz.format("%B"),
80            now_tz.format("%A")
81        );
82
83        let updated_done = add_header_and_text(&done_md, &header, &removed_md);
84        fs.write(DIR_ARCHIVE, DONE_FILENAME, &updated_done)?;
85
86        // Add journal entries for each completed task
87        let tasks = checklist_items(&removed_md);
88        for task in &tasks {
89            let stripped = strip_chat_timestamp(task);
90            let _ = journal_add_record(fs, &format!("✅ {stripped}"), tz);
91            report.journal_count += 1;
92        }
93        report.archived_count += tasks.len();
94    }
95
96    Ok(report)
97}
98
99/// Remove completed checklist items (`- [x]` / `- [X]`) from markdown.
100///
101/// Returns `(kept_content, removed_lines)`.
102pub fn remove_completed_checklist(md: &str) -> (String, String) {
103    let mut kept = Vec::new();
104    let mut removed = String::new();
105
106    for line in md.lines() {
107        let trimmed = line.trim();
108        if trimmed.starts_with("- [x] ") || trimmed.starts_with("- [X] ") {
109            removed.push_str(trimmed);
110            removed.push('\n');
111        } else {
112            kept.push(line);
113        }
114    }
115
116    (kept.join("\n"), removed)
117}
118
119/// Remove completed inbox entries (chat blocks starting with `- [x]`).
120///
121/// Returns `(surviving_content, removed_markdown)`. Removed items are
122/// formatted as `- [x] <item>` for archival in Done.md.
123pub fn remove_completed_inbox_entries(md: &str) -> (String, String) {
124    let blocks = read_chat_msgs(md);
125
126    let done_re = Regex::new(r"^- \[[xX]\] ").expect("valid regex literal");
127    // Regex that matches optional checkbox + backtick timestamp prefix
128    let ts_re = Regex::new(r"^(?:- \[[ xX]\] )?`\d{2}:\d{2}` ").expect("valid regex literal");
129
130    let mut kept: Vec<String> = Vec::new();
131    let mut removed = String::new();
132
133    for block in blocks {
134        let first_line = if let Some(nl) = block.find('\n') {
135            &block[..nl]
136        } else {
137            &block
138        };
139
140        if !done_re.is_match(first_line) {
141            kept.push(block);
142            continue;
143        }
144
145        // Strip the optional checkbox + timestamp prefix
146        let body = ts_re.replace_all(&block, "");
147        // Flatten continuation lines into spaces
148        let body = body.replace('\n', " ");
149        removed.push_str("- [x] ");
150        removed.push_str(&body);
151        removed.push('\n');
152    }
153
154    let new_md = kept.join("\n").trim().to_string();
155    (new_md, removed)
156}
157
158/// Move due scheduled tasks to Chat.md.
159///
160/// Finds schedules where `scheduled_at <= now()`, appends the task content
161/// to Chat.md, and either reschedules (if cron is set) or removes the schedule.
162///
163/// Returns list of moved task filenames.
164pub fn move_due_tasks(
165    fs: &VirtualFs,
166    config: &mut KnowledgeConfig,
167) -> Result<Vec<String>, FsError> {
168    let now_ts = Utc::now().timestamp();
169    let mut moved = Vec::new();
170
171    // Collect indices of due tasks (iterate in reverse to allow safe removal)
172    let due_indices: Vec<usize> = config
173        .schedules
174        .iter()
175        .enumerate()
176        .filter(|(_, s)| s.scheduled_at <= now_ts)
177        .map(|(i, _)| i)
178        .collect();
179
180    // Process in reverse order so index removal doesn't shift
181    for idx in due_indices.into_iter().rev() {
182        let schedule = &config.schedules[idx];
183        let filename = schedule.filename.clone();
184        let cron = schedule.cron.clone();
185
186        // Try to append the task to Chat.md
187        if let Ok(task_content) = fs.read(DIR_USER_ROOT, &filename) {
188            append_to_chat(fs, &task_content)?;
189        }
190
191        moved.push(filename.clone());
192
193        if !cron.is_empty() {
194            // Reschedule: calculate next execution time
195            if let Some(next_ts) = next_exclude_today(&cron) {
196                // Update the schedule
197                if let Some(s) = config.schedules.get_mut(idx) {
198                    s.scheduled_at = next_ts;
199                }
200            }
201        } else {
202            // One-time task: remove from schedule
203            config.schedules.remove(idx);
204        }
205    }
206
207    Ok(moved)
208}
209
210/// Generate a schedule report for display.
211///
212/// Takes a list of (display_name, scheduled_at) pairs and returns
213/// a formatted string grouped by day.
214pub fn schedule_report(schedules: &[(String, i64)]) -> String {
215    let mut day_order: Vec<String> = Vec::new();
216    let mut day_tasks: std::collections::HashMap<String, Vec<String>> =
217        std::collections::HashMap::new();
218    let now_ts = Utc::now().timestamp();
219
220    for (display_name, scheduled_at) in schedules {
221        let day = format_schedule_day(*scheduled_at, now_ts);
222        if !day_tasks.contains_key(&day) {
223            day_order.push(day.clone());
224        }
225        day_tasks.entry(day).or_default().push(display_name.clone());
226    }
227
228    let mut report = String::new();
229    for day in &day_order {
230        report.push_str(&format!("{day}\n"));
231        if let Some(tasks) = day_tasks.get(day) {
232            for task in tasks {
233                report.push_str(&format!("- {task}\n"));
234            }
235        }
236        report.push('\n');
237    }
238
239    report.trim().to_string()
240}
241
242/// Calculate next cron execution time, excluding today.
243///
244/// Supports simple "HH:MM" format (e.g., "9:00", "14:30").
245/// Returns the Unix timestamp of the next occurrence.
246pub fn next_exclude_today(cron_expr: &str) -> Option<i64> {
247    // Parse simple HH:MM format
248    let parts: Vec<&str> = cron_expr.trim().split(':').collect();
249    if parts.len() != 2 {
250        return None;
251    }
252
253    let hour: u32 = parts[0].parse().ok()?;
254    let minute: u32 = parts[1].parse().ok()?;
255
256    if hour > 23 || minute > 59 {
257        return None;
258    }
259
260    // Calculate tomorrow at HH:MM UTC
261    let now = Utc::now();
262    let tomorrow = now.date_naive() + chrono::Duration::days(1);
263    let target = tomorrow.and_hms_opt(hour, minute, 0)?.and_utc().timestamp();
264
265    Some(target)
266}
267
268// ── Internal helpers ─────────────────────────────────────────
269
270/// Parse a timezone string (e.g., "+09:00", "UTC") into a FixedOffset.
271fn parse_timezone(tz_str: &str) -> FixedOffset {
272    if tz_str == "UTC" || tz_str.is_empty() {
273        return FixedOffset::east_opt(0).expect("valid UTC offset");
274    }
275    // Try parsing "+HH:MM" or "-HH:MM"
276    if let Ok(offset) = tz_str.parse::<FixedOffset>() {
277        return offset;
278    }
279    FixedOffset::east_opt(0).expect("valid UTC offset")
280}
281
282/// Extract checklist items from markdown text.
283/// Returns the text of each `- [x]` or `- [ ]` item (trimmed).
284fn checklist_items(md: &str) -> Vec<String> {
285    let re = Regex::new(r"^- \[[ xX]\] (.+)$").expect("valid regex literal");
286    let mut items = Vec::new();
287    for line in md.lines() {
288        let trimmed = line.trim();
289        if let Some(caps) = re.captures(trimmed)
290            && let Some(m) = caps.get(1)
291        {
292            items.push(m.as_str().to_string());
293        }
294    }
295    items
296}
297
298/// Strip a leading `` `HH:MM` `` timestamp from a chat entry.
299fn strip_chat_timestamp(s: &str) -> String {
300    let re = Regex::new(r"^`\d{2}:\d{2}` ").expect("valid regex literal");
301    re.replace(s, "").to_string()
302}
303
304/// Add a header and text block to existing markdown content.
305fn add_header_and_text(existing: &str, header: &str, text: &str) -> String {
306    let mut result = existing.trim().to_string();
307    if !result.is_empty() {
308        result.push('\n');
309    }
310    result.push_str(header);
311    result.push('\n');
312    result.push_str(text.trim());
313    result
314}
315
316/// Append content to Chat.md with a timestamp header.
317fn append_to_chat(fs: &VirtualFs, content: &str) -> Result<(), FsError> {
318    let existing = match fs.read(DIR_USER_ROOT, CHAT_FILENAME) {
319        Ok(c) => c,
320        Err(FsError::Io(_)) => String::new(),
321        Err(e) => return Err(e),
322    };
323
324    let normalized = norm_new_lines(&existing);
325    let mut new_content = normalized.trim().to_string();
326    if !new_content.is_empty() {
327        new_content.push('\n');
328    }
329
330    let now = Utc::now();
331    let header = format!(
332        "#### {} {}, {}",
333        now.date_naive().day(),
334        now.format("%B"),
335        now.format("%A")
336    );
337
338    new_content.push_str(&header);
339    new_content.push('\n');
340    new_content.push_str(content.trim());
341    new_content.push('\n');
342
343    fs.write(DIR_USER_ROOT, CHAT_FILENAME, &new_content)
344}
345
346/// Format a scheduled timestamp as a human-readable day label.
347fn format_schedule_day(scheduled_at: i64, now_ts: i64) -> String {
348    let today_start = beginning_of_day(now_ts);
349    let task_start = beginning_of_day(scheduled_at);
350    let diff_days = (task_start - today_start) / 86400;
351
352    let dt = Utc
353        .timestamp_opt(scheduled_at, 0)
354        .single()
355        .expect("valid Unix timestamp");
356
357    match diff_days {
358        0 => "Today".to_string(),
359        1 => "Tomorrow".to_string(),
360        2..=6 => format!("{} {:02}", dt.format("%A"), dt.day()),
361        7..=13 => format!("Next {}", dt.format("%A %d")),
362        _ => format!("{} {}, {}", dt.format("%d %B"), dt.weekday(), dt.year()),
363    }
364}
365
366/// Calculate the beginning of a day (midnight) as a Unix timestamp.
367fn beginning_of_day(timestamp: i64) -> i64 {
368    let dt = Utc
369        .timestamp_opt(timestamp, 0)
370        .single()
371        .expect("valid Unix timestamp");
372    let date = dt.date_naive();
373    date.and_hms_milli_opt(0, 0, 0, 0)
374        .expect("midnight is always a valid time")
375        .and_utc()
376        .timestamp()
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use tempfile::TempDir;
383
384    use chrono::Timelike;
385
386    fn test_fs() -> (VirtualFs, TempDir) {
387        let dir = TempDir::new().unwrap();
388        let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
389        (fs, dir)
390    }
391
392    // ── remove_completed_checklist ──────────────────────────
393
394    #[test]
395    fn test_remove_completed_checklist() {
396        let md = "- [ ] Pending\n- [x] Done\n- [X] Also done\n- [ ] Keep";
397        let (kept, removed) = remove_completed_checklist(md);
398        assert!(kept.contains("Pending"));
399        assert!(kept.contains("Keep"));
400        assert!(!kept.contains("Done"));
401        assert!(removed.contains("- [x] Done"));
402        assert!(removed.contains("- [X] Also done"));
403    }
404
405    #[test]
406    fn test_remove_completed_checklist_no_completed() {
407        let md = "- [ ] Pending\n- [ ] Another";
408        let (kept, removed) = remove_completed_checklist(md);
409        assert_eq!(removed, "");
410        assert!(kept.contains("Pending"));
411    }
412
413    #[test]
414    fn test_remove_completed_checklist_empty() {
415        let md = "";
416        let (kept, removed) = remove_completed_checklist(md);
417        assert_eq!(kept, "");
418        assert_eq!(removed, "");
419    }
420
421    // ── remove_completed_inbox_entries ──────────────────────
422
423    #[test]
424    fn test_remove_completed_inbox_entries() {
425        let md = "#### 19 May\n- [x] `09:00` Completed task\n- [ ] `10:00` Pending task";
426        let (kept, removed) = remove_completed_inbox_entries(md);
427        assert!(kept.contains("Pending"));
428        assert!(!kept.contains("Completed"));
429        assert!(removed.contains("- [x] Completed task"));
430    }
431
432    #[test]
433    fn test_remove_completed_inbox_entries_multiline_block() {
434        let md = "- [x] `09:00` Multi\nline task\n- [ ] Keep this";
435        let (kept, removed) = remove_completed_inbox_entries(md);
436        assert!(kept.contains("Keep"));
437        assert!(removed.contains("- [x] Multi line task"));
438    }
439
440    #[test]
441    fn test_remove_completed_inbox_entries_no_completed() {
442        let md = "#### 19 May\n- [ ] `09:00` Pending\n- [ ] `10:00` Also pending";
443        let (_kept, removed) = remove_completed_inbox_entries(md);
444        assert!(removed.is_empty());
445    }
446
447    // ── remove_completed_items ──────────────────────────────
448
449    #[test]
450    fn test_remove_completed_items_basic() {
451        let (fs, _t) = test_fs();
452        fs.create_system_dirs().unwrap();
453
454        // Write Chat.md with completed items
455        fs.write(
456            DIR_USER_ROOT,
457            CHAT_FILENAME,
458            "- [x] Completed task\n- [ ] Pending task",
459        )
460        .unwrap();
461
462        let config = KnowledgeConfig::default();
463        let report = remove_completed_items(&fs, &config).unwrap();
464        assert_eq!(report.archived_count, 1); // checklist removal from Chat finds 1 item
465
466        // Chat.md should only contain the pending task
467        let chat = fs.read(DIR_USER_ROOT, CHAT_FILENAME).unwrap();
468        assert!(chat.contains("Pending"));
469        assert!(!chat.contains("Completed task"));
470
471        // Done.md should contain the completed task
472        let done = fs.read(DIR_ARCHIVE, DONE_FILENAME).unwrap();
473        assert!(done.contains("Completed task"));
474    }
475
476    #[test]
477    fn test_remove_completed_items_both_files() {
478        let (fs, _t) = test_fs();
479        fs.create_system_dirs().unwrap();
480
481        fs.write(
482            DIR_USER_ROOT,
483            CHAT_FILENAME,
484            "- [x] Chat done\n- [ ] Chat pending",
485        )
486        .unwrap();
487        fs.write(
488            DIR_USER_ROOT,
489            LATER_FILENAME,
490            "- [x] Later done\n- [ ] Later pending",
491        )
492        .unwrap();
493
494        let config = KnowledgeConfig::default();
495        let report = remove_completed_items(&fs, &config).unwrap();
496        assert!(report.archived_count >= 2);
497
498        // Later.md should only contain pending
499        let later = fs.read(DIR_USER_ROOT, LATER_FILENAME).unwrap();
500        assert!(later.contains("Later pending"));
501        assert!(!later.contains("Later done"));
502    }
503
504    // ── next_exclude_today ──────────────────────────────────
505
506    #[test]
507    fn test_next_exclude_today_valid() {
508        let result = next_exclude_today("9:00");
509        assert!(result.is_some());
510        let ts = result.unwrap();
511        // Should be in the future
512        assert!(ts > Utc::now().timestamp());
513    }
514
515    #[test]
516    fn test_next_exclude_today_invalid() {
517        assert!(next_exclude_today("invalid").is_none());
518        assert!(next_exclude_today("25:00").is_none());
519        assert!(next_exclude_today("9:60").is_none());
520        assert!(next_exclude_today("").is_none());
521    }
522
523    #[test]
524    fn test_next_exclude_today_format() {
525        let result = next_exclude_today("14:30");
526        assert!(result.is_some());
527        let ts = result.unwrap();
528        let dt = Utc
529            .timestamp_opt(ts, 0)
530            .single()
531            .expect("valid Unix timestamp");
532        assert_eq!(dt.hour(), 14);
533        assert_eq!(dt.minute(), 30);
534    }
535
536    // ── schedule_report ─────────────────────────────────────
537
538    #[test]
539    fn test_schedule_report() {
540        let now_ts = Utc::now().timestamp();
541        let schedules = vec![
542            ("Task A".to_string(), now_ts),
543            ("Task B".to_string(), now_ts + 86400),
544        ];
545        let report = schedule_report(&schedules);
546        assert!(report.contains("Today"));
547        assert!(report.contains("Tomorrow"));
548        assert!(report.contains("Task A"));
549        assert!(report.contains("Task B"));
550    }
551
552    // ── move_due_tasks ──────────────────────────────────────
553
554    #[test]
555    fn test_move_due_tasks_past_schedule() {
556        let (fs, _t) = test_fs();
557
558        let past_ts = Utc::now().timestamp() - 3600; // 1 hour ago
559        let mut config = KnowledgeConfig::default();
560        config.schedules.push(crate::types::Schedule {
561            filename: "Task.md".to_string(),
562            scheduled_at: past_ts,
563            cron: String::new(),
564            cmd: String::new(),
565        });
566
567        let moved = move_due_tasks(&fs, &mut config).unwrap();
568        assert_eq!(moved.len(), 1);
569        assert_eq!(moved[0], "Task.md");
570        // Schedule should be removed (no cron)
571        assert!(config.schedules.is_empty());
572    }
573
574    #[test]
575    fn test_move_due_tasks_future_schedule() {
576        let (fs, _t) = test_fs();
577
578        let future_ts = Utc::now().timestamp() + 86400; // tomorrow
579        let mut config = KnowledgeConfig::default();
580        config.schedules.push(crate::types::Schedule {
581            filename: "Task.md".to_string(),
582            scheduled_at: future_ts,
583            cron: String::new(),
584            cmd: String::new(),
585        });
586
587        let moved = move_due_tasks(&fs, &mut config).unwrap();
588        assert!(moved.is_empty());
589        assert_eq!(config.schedules.len(), 1);
590    }
591
592    #[test]
593    fn test_move_due_tasks_cron_reschedules() {
594        let (fs, _t) = test_fs();
595
596        let past_ts = Utc::now().timestamp() - 3600;
597        let mut config = KnowledgeConfig::default();
598        config.schedules.push(crate::types::Schedule {
599            filename: "Recurring.md".to_string(),
600            scheduled_at: past_ts,
601            cron: "9:00".to_string(),
602            cmd: String::new(),
603        });
604
605        let moved = move_due_tasks(&fs, &mut config).unwrap();
606        assert_eq!(moved.len(), 1);
607        // Should still have the schedule (rescheduled)
608        assert_eq!(config.schedules.len(), 1);
609        assert!(config.schedules[0].scheduled_at > Utc::now().timestamp());
610    }
611
612    // ── add_header_and_text ─────────────────────────────────
613
614    #[test]
615    fn test_add_header_and_text() {
616        let result = add_header_and_text("existing", "#### Header", "some text");
617        assert!(result.contains("existing"));
618        assert!(result.contains("#### Header"));
619        assert!(result.contains("some text"));
620    }
621
622    #[test]
623    fn test_add_header_and_text_empty_existing() {
624        let result = add_header_and_text("", "#### Header", "some text");
625        assert!(result.starts_with("#### Header"));
626    }
627}