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;
15
16/// AIM calendar application core.
17#[derive(Debug, Clone)]
18pub struct Aim {
19    cache: SqliteCache,
20    calendar_path: PathBuf,
21}
22
23impl Aim {
24    /// Creates a new AIM instance with the given configuration.
25    pub async fn new(config: &Config) -> Result<Self, Box<dyn Error>> {
26        let cache = SqliteCache::open()
27            .await
28            .map_err(|e| format!("Failed to initialize cache: {e}"))?;
29
30        let that = Self {
31            cache,
32            calendar_path: config.calendar_path.clone(),
33        };
34        that.add_calendar(&config.calendar_path)
35            .await
36            .map_err(|e| format!("Failed to add calendar files: {e}"))?;
37
38        Ok(that)
39    }
40
41    /// List events matching the given conditions.
42    pub async fn list_events(
43        &self,
44        conds: &EventConditions,
45        pager: &Pager,
46    ) -> Result<Vec<impl Event>, sqlx::Error> {
47        self.cache.events.list(conds, pager).await
48    }
49
50    /// Counts the number of events matching the given conditions.
51    pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, sqlx::Error> {
52        self.cache.events.count(conds).await
53    }
54
55    /// Add a new todo from the given draft.
56    pub async fn new_todo(&self, draft: TodoDraft) -> Result<impl Todo, Box<dyn Error>> {
57        if self.cache.todos.get(&draft.uid).await?.is_some() {
58            return Err("Todo with this UID already exists".into());
59        }
60
61        let path = self.calendar_path.join(format!("{}.ics", draft.uid));
62        if fs::try_exists(&path).await? {
63            return Err(format!("File already exists: {}", path.display()).into());
64        }
65
66        let todo = draft.into_todo()?;
67        let calendar = Calendar::new().push(todo.clone()).done();
68        fs::write(&path, calendar.to_string())
69            .await
70            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
71
72        self.cache.upsert_todo(&path, &todo).await?;
73        Ok(todo)
74    }
75
76    /// Upsert an event into the calendar.
77    pub async fn update_todo(&self, patch: TodoPatch) -> Result<impl Todo, Box<dyn Error>> {
78        let todo = match self.cache.todos.get(&patch.uid).await? {
79            Some(todo) => todo,
80            None => return Err("Todo not found".into()),
81        };
82
83        let path: PathBuf = todo.path().into();
84        let mut calendar = parse_ics(&path).await?;
85        let t = calendar
86            .components
87            .iter_mut()
88            .filter_map(|a| match a {
89                CalendarComponent::Todo(a) => Some(a),
90                _ => None,
91            })
92            .find(|a| a.get_uid() == Some(todo.uid()))
93            .ok_or("Todo not found in calendar")?;
94
95        patch.apply_to(t);
96        let todo = t.clone();
97        fs::write(&path, calendar.done().to_string())
98            .await
99            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
100
101        self.cache.upsert_todo(&path, &todo).await?;
102        Ok(todo)
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
174/// Configuration for the AIM application.
175#[derive(Debug)]
176pub struct Config {
177    /// Path to the calendar directory.
178    pub calendar_path: PathBuf,
179}
180
181async fn parse_ics(path: &Path) -> Result<Calendar, Box<dyn Error>> {
182    fs::read_to_string(path)
183        .await
184        .map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?
185        .parse()
186        .map_err(|e| format!("Failed to parse calendar: {e}").into())
187}