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, TodoDraft, 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;
15use uuid::Uuid;
16
17/// AIM calendar application core.
18#[derive(Debug, Clone)]
19pub struct Aim {
20    cache: SqliteCache,
21    calendar_path: PathBuf,
22}
23
24impl Aim {
25    /// Creates a new AIM instance with the given configuration.
26    pub async fn new(config: &Config) -> Result<Self, Box<dyn Error>> {
27        let cache = SqliteCache::open()
28            .await
29            .map_err(|e| format!("Failed to initialize cache: {e}"))?;
30
31        let that = Self {
32            cache,
33            calendar_path: config.calendar_path.clone(),
34        };
35        that.add_calendar(&config.calendar_path)
36            .await
37            .map_err(|e| format!("Failed to add calendar files: {e}"))?;
38
39        Ok(that)
40    }
41
42    /// List events matching the given conditions.
43    pub async fn list_events(
44        &self,
45        conds: &EventConditions,
46        pager: &Pager,
47    ) -> Result<Vec<impl Event>, sqlx::Error> {
48        self.cache.events.list(conds, pager).await
49    }
50
51    /// Counts the number of events matching the given conditions.
52    pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, sqlx::Error> {
53        self.cache.events.count(conds).await
54    }
55
56    /// Add a new todo from the given draft.
57    pub async fn new_todo(&self, draft: TodoDraft) -> Result<impl Todo, Box<dyn Error>> {
58        let uid = self.generate_uid().await?;
59        let todo = draft.into_todo(&uid);
60        let path = self.get_path(&uid);
61
62        let calendar = Calendar::new().push(todo.clone()).done();
63        fs::write(&path, calendar.to_string())
64            .await
65            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
66
67        self.cache.upsert_todo(&path, &todo).await?;
68        Ok(todo)
69    }
70
71    /// Upsert an event into the calendar.
72    pub async fn update_todo(&self, patch: TodoPatch) -> Result<impl Todo, Box<dyn Error>> {
73        let todo = match self.cache.todos.get(&patch.uid).await? {
74            Some(todo) => todo,
75            None => return Err("Todo not found".into()),
76        };
77
78        let path: PathBuf = todo.path().into();
79        let mut calendar = parse_ics(&path).await?;
80        let t = calendar
81            .components
82            .iter_mut()
83            .filter_map(|a| match a {
84                CalendarComponent::Todo(a) => Some(a),
85                _ => None,
86            })
87            .find(|a| a.get_uid() == Some(todo.uid()))
88            .ok_or("Todo not found in calendar")?;
89
90        patch.apply_to(t);
91        let todo = t.clone();
92        fs::write(&path, calendar.done().to_string())
93            .await
94            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
95
96        self.cache.upsert_todo(&path, &todo).await?;
97        Ok(todo)
98    }
99
100    /// Get a todo by its UID.
101    pub async fn get_todo(&self, uid: &str) -> Result<Option<impl Todo>, sqlx::Error> {
102        self.cache.todos.get(uid).await
103    }
104
105    /// List todos matching the given conditions, sorted and paginated.
106    pub async fn list_todos(
107        &self,
108        conds: &TodoConditions,
109        sort: &[TodoSort],
110        pager: &Pager,
111    ) -> Result<Vec<impl Todo>, sqlx::Error> {
112        self.cache.todos.list(conds, sort, pager).await
113    }
114
115    /// Counts the number of todos matching the given conditions.
116    pub async fn count_todos(&self, conds: &TodoConditions) -> Result<i64, sqlx::Error> {
117        self.cache.todos.count(conds).await
118    }
119
120    async fn add_calendar(&self, calendar_path: &PathBuf) -> Result<(), Box<dyn Error>> {
121        let mut reader = fs::read_dir(calendar_path)
122            .await
123            .map_err(|e| format!("Failed to read directory: {e}"))?;
124
125        let mut handles = vec![];
126        let mut count_ics = 0;
127
128        while let Some(entry) = reader.next_entry().await? {
129            let path = entry.path();
130            match path.extension() {
131                Some(ext) if ext == "ics" => {
132                    count_ics += 1;
133                    let that = self.clone();
134                    handles.push(tokio::spawn(async move {
135                        if let Err(e) = that.add_ics(&path).await {
136                            log::error!("Failed to process file {}: {}", path.display(), e);
137                        }
138                    }));
139                }
140                _ => {}
141            }
142        }
143
144        for handle in handles {
145            handle.await?;
146        }
147
148        log::debug!("Total .ics files processed: {count_ics}");
149        Ok(())
150    }
151
152    async fn add_ics(self, path: &Path) -> Result<(), Box<dyn Error>> {
153        log::debug!("Parsing file: {}", path.display());
154        let calendar = parse_ics(path).await?;
155        log::debug!(
156            "Found {} components in {}.",
157            calendar.components.len(),
158            path.display()
159        );
160
161        for component in calendar.components {
162            log::debug!("Processing component: {component:?}");
163            match component {
164                CalendarComponent::Event(event) => self.cache.upsert_event(path, &event).await?,
165                CalendarComponent::Todo(todo) => self.cache.upsert_todo(path, &todo).await?,
166                _ => log::warn!("Ignoring unsupported component type: {component:?}"),
167            }
168        }
169
170        Ok(())
171    }
172
173    async fn generate_uid(&self) -> Result<String, Box<dyn Error>> {
174        for _ in 0..16 {
175            let uid = Uuid::new_v4().to_string(); // TODO: better uid
176            if self.cache.todos.get(&uid).await?.is_some()
177                || fs::try_exists(&self.get_path(&uid)).await?
178            {
179                continue;
180            }
181            return Ok(uid);
182        }
183
184        Err("Failed to generate a unique UID after multiple attempts".into())
185    }
186
187    fn get_path(&self, uid: &str) -> PathBuf {
188        self.calendar_path.join(format!("{uid}.ics"))
189    }
190}
191
192/// Configuration for the AIM application.
193#[derive(Debug)]
194pub struct Config {
195    /// Path to the calendar directory.
196    pub calendar_path: PathBuf,
197}
198
199async fn parse_ics(path: &Path) -> Result<Calendar, Box<dyn Error>> {
200    fs::read_to_string(path)
201        .await
202        .map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?
203        .parse()
204        .map_err(|e| format!("Failed to parse calendar: {e}").into())
205}