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