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    FsError, KnowledgeConfig, CHAT_FILENAME, DIR_ARCHIVE, DIR_USER_ROOT, DONE_FILENAME,
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]\] ").unwrap();
127    // Regex that matches optional checkbox + backtick timestamp prefix
128    let ts_re = Regex::new(r"^(?:- \[[ xX]\] )?`\d{2}:\d{2}` ").unwrap();
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).unwrap();
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).unwrap()
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]\] (.+)$").unwrap();
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            if let Some(m) = caps.get(1) {
291                items.push(m.as_str().to_string());
292            }
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}` ").unwrap();
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.timestamp_opt(scheduled_at, 0).unwrap();
353
354    match diff_days {
355        0 => "Today".to_string(),
356        1 => "Tomorrow".to_string(),
357        2..=6 => format!("{} {:02}", dt.format("%A"), dt.day()),
358        7..=13 => format!("Next {}", dt.format("%A %d")),
359        _ => format!("{} {}, {}", dt.format("%d %B"), dt.weekday(), dt.year()),
360    }
361}
362
363/// Calculate the beginning of a day (midnight) as a Unix timestamp.
364fn beginning_of_day(timestamp: i64) -> i64 {
365    let dt = Utc.timestamp_opt(timestamp, 0).unwrap();
366    let date = dt.date_naive();
367    date.and_hms_milli_opt(0, 0, 0, 0)
368        .unwrap()
369        .and_utc()
370        .timestamp()
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use tempfile::TempDir;
377
378    use chrono::Timelike;
379
380    fn test_fs() -> (VirtualFs, TempDir) {
381        let dir = TempDir::new().unwrap();
382        let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
383        (fs, dir)
384    }
385
386    // ── remove_completed_checklist ──────────────────────────
387
388    #[test]
389    fn test_remove_completed_checklist() {
390        let md = "- [ ] Pending\n- [x] Done\n- [X] Also done\n- [ ] Keep";
391        let (kept, removed) = remove_completed_checklist(md);
392        assert!(kept.contains("Pending"));
393        assert!(kept.contains("Keep"));
394        assert!(!kept.contains("Done"));
395        assert!(removed.contains("- [x] Done"));
396        assert!(removed.contains("- [X] Also done"));
397    }
398
399    #[test]
400    fn test_remove_completed_checklist_no_completed() {
401        let md = "- [ ] Pending\n- [ ] Another";
402        let (kept, removed) = remove_completed_checklist(md);
403        assert_eq!(removed, "");
404        assert!(kept.contains("Pending"));
405    }
406
407    #[test]
408    fn test_remove_completed_checklist_empty() {
409        let md = "";
410        let (kept, removed) = remove_completed_checklist(md);
411        assert_eq!(kept, "");
412        assert_eq!(removed, "");
413    }
414
415    // ── remove_completed_inbox_entries ──────────────────────
416
417    #[test]
418    fn test_remove_completed_inbox_entries() {
419        let md = "#### 19 May\n- [x] `09:00` Completed task\n- [ ] `10:00` Pending task";
420        let (kept, removed) = remove_completed_inbox_entries(md);
421        assert!(kept.contains("Pending"));
422        assert!(!kept.contains("Completed"));
423        assert!(removed.contains("- [x] Completed task"));
424    }
425
426    #[test]
427    fn test_remove_completed_inbox_entries_multiline_block() {
428        let md = "- [x] `09:00` Multi\nline task\n- [ ] Keep this";
429        let (kept, removed) = remove_completed_inbox_entries(md);
430        assert!(kept.contains("Keep"));
431        assert!(removed.contains("- [x] Multi line task"));
432    }
433
434    #[test]
435    fn test_remove_completed_inbox_entries_no_completed() {
436        let md = "#### 19 May\n- [ ] `09:00` Pending\n- [ ] `10:00` Also pending";
437        let (_kept, removed) = remove_completed_inbox_entries(md);
438        assert!(removed.is_empty());
439    }
440
441    // ── remove_completed_items ──────────────────────────────
442
443    #[test]
444    fn test_remove_completed_items_basic() {
445        let (fs, _t) = test_fs();
446        fs.create_system_dirs().unwrap();
447
448        // Write Chat.md with completed items
449        fs.write(
450            DIR_USER_ROOT,
451            CHAT_FILENAME,
452            "- [x] Completed task\n- [ ] Pending task",
453        )
454        .unwrap();
455
456        let config = KnowledgeConfig::default();
457        let report = remove_completed_items(&fs, &config).unwrap();
458        assert_eq!(report.archived_count, 1); // checklist removal from Chat finds 1 item
459
460        // Chat.md should only contain the pending task
461        let chat = fs.read(DIR_USER_ROOT, CHAT_FILENAME).unwrap();
462        assert!(chat.contains("Pending"));
463        assert!(!chat.contains("Completed task"));
464
465        // Done.md should contain the completed task
466        let done = fs.read(DIR_ARCHIVE, DONE_FILENAME).unwrap();
467        assert!(done.contains("Completed task"));
468    }
469
470    #[test]
471    fn test_remove_completed_items_both_files() {
472        let (fs, _t) = test_fs();
473        fs.create_system_dirs().unwrap();
474
475        fs.write(
476            DIR_USER_ROOT,
477            CHAT_FILENAME,
478            "- [x] Chat done\n- [ ] Chat pending",
479        )
480        .unwrap();
481        fs.write(
482            DIR_USER_ROOT,
483            LATER_FILENAME,
484            "- [x] Later done\n- [ ] Later pending",
485        )
486        .unwrap();
487
488        let config = KnowledgeConfig::default();
489        let report = remove_completed_items(&fs, &config).unwrap();
490        assert!(report.archived_count >= 2);
491
492        // Later.md should only contain pending
493        let later = fs.read(DIR_USER_ROOT, LATER_FILENAME).unwrap();
494        assert!(later.contains("Later pending"));
495        assert!(!later.contains("Later done"));
496    }
497
498    // ── next_exclude_today ──────────────────────────────────
499
500    #[test]
501    fn test_next_exclude_today_valid() {
502        let result = next_exclude_today("9:00");
503        assert!(result.is_some());
504        let ts = result.unwrap();
505        // Should be in the future
506        assert!(ts > Utc::now().timestamp());
507    }
508
509    #[test]
510    fn test_next_exclude_today_invalid() {
511        assert!(next_exclude_today("invalid").is_none());
512        assert!(next_exclude_today("25:00").is_none());
513        assert!(next_exclude_today("9:60").is_none());
514        assert!(next_exclude_today("").is_none());
515    }
516
517    #[test]
518    fn test_next_exclude_today_format() {
519        let result = next_exclude_today("14:30");
520        assert!(result.is_some());
521        let ts = result.unwrap();
522        let dt = Utc.timestamp_opt(ts, 0).unwrap();
523        assert_eq!(dt.hour(), 14);
524        assert_eq!(dt.minute(), 30);
525    }
526
527    // ── schedule_report ─────────────────────────────────────
528
529    #[test]
530    fn test_schedule_report() {
531        let now_ts = Utc::now().timestamp();
532        let schedules = vec![
533            ("Task A".to_string(), now_ts),
534            ("Task B".to_string(), now_ts + 86400),
535        ];
536        let report = schedule_report(&schedules);
537        assert!(report.contains("Today"));
538        assert!(report.contains("Tomorrow"));
539        assert!(report.contains("Task A"));
540        assert!(report.contains("Task B"));
541    }
542
543    // ── move_due_tasks ──────────────────────────────────────
544
545    #[test]
546    fn test_move_due_tasks_past_schedule() {
547        let (fs, _t) = test_fs();
548
549        let past_ts = Utc::now().timestamp() - 3600; // 1 hour ago
550        let mut config = KnowledgeConfig::default();
551        config.schedules.push(crate::types::Schedule {
552            filename: "Task.md".to_string(),
553            scheduled_at: past_ts,
554            cron: String::new(),
555            cmd: String::new(),
556        });
557
558        let moved = move_due_tasks(&fs, &mut config).unwrap();
559        assert_eq!(moved.len(), 1);
560        assert_eq!(moved[0], "Task.md");
561        // Schedule should be removed (no cron)
562        assert!(config.schedules.is_empty());
563    }
564
565    #[test]
566    fn test_move_due_tasks_future_schedule() {
567        let (fs, _t) = test_fs();
568
569        let future_ts = Utc::now().timestamp() + 86400; // tomorrow
570        let mut config = KnowledgeConfig::default();
571        config.schedules.push(crate::types::Schedule {
572            filename: "Task.md".to_string(),
573            scheduled_at: future_ts,
574            cron: String::new(),
575            cmd: String::new(),
576        });
577
578        let moved = move_due_tasks(&fs, &mut config).unwrap();
579        assert!(moved.is_empty());
580        assert_eq!(config.schedules.len(), 1);
581    }
582
583    #[test]
584    fn test_move_due_tasks_cron_reschedules() {
585        let (fs, _t) = test_fs();
586
587        let past_ts = Utc::now().timestamp() - 3600;
588        let mut config = KnowledgeConfig::default();
589        config.schedules.push(crate::types::Schedule {
590            filename: "Recurring.md".to_string(),
591            scheduled_at: past_ts,
592            cron: "9:00".to_string(),
593            cmd: String::new(),
594        });
595
596        let moved = move_due_tasks(&fs, &mut config).unwrap();
597        assert_eq!(moved.len(), 1);
598        // Should still have the schedule (rescheduled)
599        assert_eq!(config.schedules.len(), 1);
600        assert!(config.schedules[0].scheduled_at > Utc::now().timestamp());
601    }
602
603    // ── add_header_and_text ─────────────────────────────────
604
605    #[test]
606    fn test_add_header_and_text() {
607        let result = add_header_and_text("existing", "#### Header", "some text");
608        assert!(result.contains("existing"));
609        assert!(result.contains("#### Header"));
610        assert!(result.contains("some text"));
611    }
612
613    #[test]
614    fn test_add_header_and_text_empty_existing() {
615        let result = add_header_and_text("", "#### Header", "some text");
616        assert!(result.starts_with("#### Header"));
617    }
618}