aimcal_core/
aim.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::error::Error;
6use std::path::{Path, PathBuf};
7
8use chrono::{DateTime, Local};
9use icalendar::{Calendar, CalendarComponent, Component};
10use tokio::fs;
11use uuid::Uuid;
12
13use crate::event::ParsedEventConditions;
14use crate::localdb::LocalDb;
15use crate::short_id::ShortIds;
16use crate::todo::{ParsedTodoConditions, ParsedTodoSort, TodoPatch};
17use crate::{Config, Event, EventConditions, Id, Pager, Todo, TodoConditions, TodoDraft, TodoSort};
18
19/// AIM calendar application core.
20#[derive(Debug, Clone)]
21pub struct Aim {
22    now: DateTime<Local>,
23    config: Config,
24    db: LocalDb,
25    short_ids: ShortIds,
26    calendar_path: PathBuf,
27}
28
29impl Aim {
30    /// Creates a new AIM instance with the given configuration.
31    pub async fn new(mut config: Config) -> Result<Self, Box<dyn Error>> {
32        let now = Local::now();
33
34        config.normalize()?;
35        prepare(&config).await?;
36
37        let db = LocalDb::open(&config.state_dir)
38            .await
39            .map_err(|e| format!("Failed to initialize db: {e}"))?;
40
41        let short_ids = ShortIds::new(db.clone());
42        let calendar_path = config.calendar_path.clone();
43        let that = Self {
44            now,
45            config,
46            db,
47            short_ids,
48            calendar_path,
49        };
50        that.add_calendar(&that.calendar_path)
51            .await
52            .map_err(|e| format!("Failed to add calendar files: {e}"))?;
53
54        Ok(that)
55    }
56
57    /// Returns the current time in the AIM instance.
58    pub fn now(&self) -> DateTime<Local> {
59        self.now
60    }
61
62    /// Refresh the current time to now.
63    pub fn refresh_now(&mut self) {
64        self.now = Local::now();
65    }
66
67    /// List events matching the given conditions.
68    pub async fn list_events(
69        &self,
70        conds: &EventConditions,
71        pager: &Pager,
72    ) -> Result<Vec<impl Event>, Box<dyn Error>> {
73        let conds = ParsedEventConditions::parse(&self.now, conds);
74        let events = self.db.events.list(&conds, pager).await?;
75        let events = self.short_ids.events(events).await?;
76        Ok(events)
77    }
78
79    /// Counts the number of events matching the given conditions.
80    pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, sqlx::Error> {
81        let conds = ParsedEventConditions::parse(&self.now, conds);
82        self.db.events.count(&conds).await
83    }
84
85    /// Create a default todo draft based on the AIM configuration.
86    pub fn default_todo_draft(&self) -> TodoDraft {
87        TodoDraft::default(&self.config, self.now)
88    }
89
90    /// Add a new todo from the given draft.
91    pub async fn new_todo(&self, draft: TodoDraft) -> Result<impl Todo, Box<dyn Error>> {
92        let uid = self.generate_uid().await?;
93        let todo = draft.into_todo(&self.config, self.now, &uid);
94        let path = self.get_path(&uid);
95
96        let calendar = Calendar::new().push(todo.clone()).done();
97        fs::write(&path, calendar.to_string())
98            .await
99            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
100
101        self.db.upsert_todo(&path, &todo).await?;
102
103        let todo = self.short_ids.todo(todo).await?;
104        Ok(todo)
105    }
106
107    /// Upsert an event into the calendar.
108    pub async fn update_todo(
109        &self,
110        id: &Id,
111        patch: TodoPatch,
112    ) -> Result<impl Todo, Box<dyn Error>> {
113        let uid = self.short_ids.get_uid(id).await?;
114        let todo = match self.db.todos.get(&uid).await? {
115            Some(todo) => todo,
116            None => return Err("Todo not found".into()),
117        };
118
119        let path: PathBuf = todo.path().into();
120        let mut calendar = parse_ics(&path).await?;
121        let t = calendar
122            .components
123            .iter_mut()
124            .filter_map(|a| match a {
125                CalendarComponent::Todo(a) => Some(a),
126                _ => None,
127            })
128            .find(|a| a.get_uid() == Some(todo.uid()))
129            .ok_or("Todo not found in calendar")?;
130
131        patch.apply_to(t);
132        let todo = t.clone();
133        fs::write(&path, calendar.done().to_string())
134            .await
135            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
136
137        self.db.upsert_todo(&path, &todo).await?;
138
139        let todo = self.short_ids.todo(todo).await?;
140        Ok(todo)
141    }
142
143    /// Get a todo by its UID.
144    pub async fn get_todo(&self, id: &Id) -> Result<Option<impl Todo>, Box<dyn Error>> {
145        let uid = self.short_ids.get_uid(id).await?;
146        match self.db.todos.get(&uid).await {
147            Ok(Some(todo)) => {
148                let todo = self.short_ids.todo(todo).await?;
149                Ok(Some(todo))
150            }
151            Ok(None) => Ok(None),
152            Err(e) => Err(e.into()),
153        }
154    }
155
156    /// List todos matching the given conditions, sorted and paginated.
157    pub async fn list_todos(
158        &self,
159        conds: &TodoConditions,
160        sort: &[TodoSort],
161        pager: &Pager,
162    ) -> Result<Vec<impl Todo>, Box<dyn Error>> {
163        let conds = ParsedTodoConditions::parse(&self.now, conds);
164        let sort = ParsedTodoSort::parse_vec(&self.config, sort);
165        let todos = self.db.todos.list(&conds, &sort, pager).await?;
166        let todos = self.short_ids.todos(todos).await?;
167        Ok(todos)
168    }
169
170    /// Counts the number of todos matching the given conditions.
171    pub async fn count_todos(&self, conds: &TodoConditions) -> Result<i64, sqlx::Error> {
172        let conds = ParsedTodoConditions::parse(&self.now, conds);
173        self.db.todos.count(&conds).await
174    }
175
176    /// Close the AIM instance, saving any changes to the database.
177    pub async fn close(self) -> Result<(), Box<dyn Error>> {
178        self.db.close().await?;
179        Ok(())
180    }
181
182    async fn add_calendar(&self, calendar_path: &PathBuf) -> Result<(), Box<dyn Error>> {
183        let mut reader = fs::read_dir(calendar_path)
184            .await
185            .map_err(|e| format!("Failed to read directory: {e}"))?;
186
187        let mut handles = vec![];
188        let mut count_ics = 0;
189
190        while let Some(entry) = reader.next_entry().await? {
191            let path = entry.path();
192            match path.extension() {
193                Some(ext) if ext == "ics" => {
194                    count_ics += 1;
195                    let that = self.clone();
196                    handles.push(tokio::spawn(async move {
197                        if let Err(e) = that.add_ics(&path).await {
198                            log::error!("Failed to process file {}: {}", path.display(), e);
199                        }
200                    }));
201                }
202                _ => {}
203            }
204        }
205
206        for handle in handles {
207            handle.await?;
208        }
209
210        log::debug!("Total .ics files processed: {count_ics}");
211        Ok(())
212    }
213
214    async fn add_ics(self, path: &Path) -> Result<(), Box<dyn Error>> {
215        log::debug!("Parsing file: {}", path.display());
216        let calendar = parse_ics(path).await?;
217        log::debug!(
218            "Found {} components in {}.",
219            calendar.components.len(),
220            path.display()
221        );
222
223        for component in calendar.components {
224            log::debug!("Processing component: {component:?}");
225            match component {
226                CalendarComponent::Event(event) => self.db.upsert_event(path, &event).await?,
227                CalendarComponent::Todo(todo) => self.db.upsert_todo(path, &todo).await?,
228                _ => log::warn!("Ignoring unsupported component type: {component:?}"),
229            }
230        }
231
232        Ok(())
233    }
234
235    async fn generate_uid(&self) -> Result<String, Box<dyn Error>> {
236        for _ in 0..16 {
237            let uid = Uuid::new_v4().to_string(); // TODO: better uid
238            if self.db.todos.get(&uid).await?.is_some()
239                || fs::try_exists(&self.get_path(&uid)).await?
240            {
241                continue;
242            }
243            return Ok(uid);
244        }
245
246        Err("Failed to generate a unique UID after multiple attempts".into())
247    }
248
249    fn get_path(&self, uid: &str) -> PathBuf {
250        self.calendar_path.join(format!("{uid}.ics"))
251    }
252}
253
254async fn prepare(config: &Config) -> Result<(), Box<dyn Error>> {
255    if let Some(parent) = &config.state_dir {
256        log::info!("Ensuring state directory exists: {}", parent.display());
257        fs::create_dir_all(parent).await?;
258    }
259    Ok(())
260}
261
262async fn parse_ics(path: &Path) -> Result<Calendar, Box<dyn Error>> {
263    fs::read_to_string(path)
264        .await
265        .map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?
266        .parse()
267        .map_err(|e| format!("Failed to parse calendar: {e}").into())
268}