aimcal_core/
aim.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use crate::{
6    Event, EventConditions, Pager, Todo, TodoConditions, TodoSort, cache::SqliteCache,
7    todo::TodoPatch,
8};
9use icalendar::{Calendar, CalendarComponent, Component};
10use std::{
11    error::Error,
12    path::{Path, PathBuf},
13};
14use tokio::fs;
15
16/// AIM calendar application core.
17#[derive(Debug, Clone)]
18pub struct Aim {
19    cache: SqliteCache,
20}
21
22impl Aim {
23    /// Creates a new AIM instance with the given configuration.
24    pub async fn new(config: &Config) -> Result<Self, Box<dyn Error>> {
25        let cache = SqliteCache::open()
26            .await
27            .map_err(|e| format!("Failed to initialize cache: {e}"))?;
28
29        let that = Self { cache };
30        that.add_calendar(&config.calendar_path)
31            .await
32            .map_err(|e| format!("Failed to add calendar files: {e}"))?;
33
34        Ok(that)
35    }
36
37    /// List events matching the given conditions.
38    pub async fn list_events(
39        &self,
40        conds: &EventConditions,
41        pager: &Pager,
42    ) -> Result<Vec<impl Event>, sqlx::Error> {
43        self.cache.events.list(conds, pager).await
44    }
45
46    /// Counts the number of events matching the given conditions.
47    pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, sqlx::Error> {
48        self.cache.events.count(conds).await
49    }
50
51    /// Upsert an event into the calendar.
52    pub async fn upsert_todo(&self, patch: TodoPatch) -> Result<impl Todo, Box<dyn Error>> {
53        let mut todo = match self.cache.todos.get(&patch.uid).await? {
54            Some(todo) => todo,
55            None => return Err("Todo not found".into()),
56        };
57
58        let path: PathBuf = todo.path().into();
59        let mut calendar = read_calendar_file(&path).await?;
60        let mut flag = true;
61        for component in &mut calendar.components.iter_mut() {
62            if let CalendarComponent::Todo(t) = component {
63                if t.get_uid() == Some(todo.uid()) {
64                    patch.apply_to(t);
65                    flag = false;
66                    break;
67                }
68            }
69        }
70        if flag {
71            return Err("Todo not found in calendar".into());
72        }
73        let ics = calendar.done();
74        fs::write(path, ics.to_string())
75            .await
76            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
77
78        todo.apply(patch);
79        self.cache.todos.upsert(&todo).await?;
80
81        Ok(todo)
82    }
83
84    /// List todos matching the given conditions, sorted and paginated.
85    pub async fn list_todos(
86        &self,
87        conds: &TodoConditions,
88        sort: &[TodoSort],
89        pager: &Pager,
90    ) -> Result<Vec<impl Todo>, sqlx::Error> {
91        self.cache.todos.list(conds, sort, pager).await
92    }
93
94    /// Counts the number of todos matching the given conditions.
95    pub async fn count_todos(&self, conds: &TodoConditions) -> Result<i64, sqlx::Error> {
96        self.cache.todos.count(conds).await
97    }
98
99    async fn add_calendar(&self, calendar_path: &PathBuf) -> Result<(), Box<dyn Error>> {
100        let mut reader = fs::read_dir(calendar_path)
101            .await
102            .map_err(|e| format!("Failed to read directory: {e}"))?;
103
104        let mut handles = vec![];
105        let mut count_ics = 0;
106
107        while let Some(entry) = reader.next_entry().await? {
108            let path = entry.path();
109            match path.extension() {
110                Some(ext) if ext == "ics" => {
111                    count_ics += 1;
112                    let that = self.clone();
113                    handles.push(tokio::spawn(async move {
114                        if let Err(e) = that.add_ics(&path).await {
115                            log::error!("Failed to process file {}: {}", path.display(), e);
116                        }
117                    }));
118                }
119                _ => {}
120            }
121        }
122
123        for handle in handles {
124            handle.await?;
125        }
126
127        log::debug!("Total .ics files processed: {count_ics}");
128        Ok(())
129    }
130
131    async fn add_ics(self, path: &Path) -> Result<(), Box<dyn Error>> {
132        log::debug!("Parsing file: {}", path.display());
133        let calendar = read_calendar_file(path).await?;
134        log::debug!(
135            "Found {} components in {}.",
136            calendar.components.len(),
137            path.display()
138        );
139
140        for component in calendar.components {
141            log::debug!("Processing component: {component:?}");
142            match component {
143                CalendarComponent::Event(event) => self.cache.upsert_event(path, event).await?,
144                CalendarComponent::Todo(todo) => self.cache.upsert_todo(path, todo).await?,
145                _ => log::warn!("Ignoring unsupported component type: {component:?}"),
146            }
147        }
148
149        Ok(())
150    }
151}
152
153/// Configuration for the AIM application.
154#[derive(Debug)]
155pub struct Config {
156    /// Path to the calendar directory.
157    pub calendar_path: PathBuf,
158}
159
160async fn read_calendar_file(path: &Path) -> Result<Calendar, Box<dyn Error>> {
161    fs::read_to_string(path)
162        .await
163        .map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?
164        .parse()
165        .map_err(|e| format!("Failed to parse calendar: {e}").into())
166}