aimcal_core/
aim.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::error::Error;
6use std::path::PathBuf;
7
8use chrono::{DateTime, Local};
9use icalendar::{Calendar, CalendarComponent, Component};
10use tokio::fs;
11use uuid::Uuid;
12
13use crate::io::{add_calendar, parse_ics};
14use crate::localdb::LocalDb;
15use crate::short_id::ShortIds;
16use crate::{
17    Config, Event, EventConditions, EventDraft, EventPatch, Id, Kind, Pager, Todo, TodoConditions,
18    TodoDraft, TodoPatch, TodoSort,
19};
20
21/// AIM calendar application core.
22#[derive(Debug, Clone)]
23pub struct Aim {
24    now: DateTime<Local>,
25    config: Config,
26    db: LocalDb,
27    short_ids: ShortIds,
28    calendar_path: PathBuf,
29}
30
31impl Aim {
32    /// Creates a new AIM instance with the given configuration.
33    ///
34    /// # Errors
35    /// If initialization fails.
36    pub async fn new(mut config: Config) -> Result<Self, Box<dyn Error>> {
37        let now = Local::now();
38
39        config.normalize()?;
40        prepare(&config).await?;
41
42        let db = initialize_db(&config).await?;
43        let short_ids = ShortIds::new(db.clone());
44        let calendar_path = config.calendar_path.clone();
45        add_calendar(&db, &calendar_path)
46            .await
47            .map_err(|e| format!("Failed to add calendar files: {e}"))?;
48
49        Ok(Self {
50            now,
51            config,
52            db,
53            short_ids,
54            calendar_path,
55        })
56    }
57
58    /// The current time in the AIM instance.
59    #[must_use]
60    pub fn now(&self) -> DateTime<Local> {
61        self.now
62    }
63
64    /// Refresh the current time to now.
65    pub fn refresh_now(&mut self) {
66        self.now = Local::now();
67    }
68
69    /// Create a default event draft based on the AIM configuration.
70    #[must_use]
71    pub fn default_event_draft(&self) -> EventDraft {
72        EventDraft::default(&self.now)
73    }
74
75    /// Get a event by its id.
76    ///
77    /// # Errors
78    /// If the event is not found or database access fails.
79    pub async fn get_event(&self, id: &Id) -> Result<impl Event + 'static, Box<dyn Error>> {
80        let uid = self.short_ids.get_uid(id).await?;
81        match self.db.events.get(&uid).await {
82            Ok(Some(event)) => Ok(self.short_ids.event(event).await?),
83            Ok(None) => Err("Event not found".into()),
84            Err(e) => Err(e.into()),
85        }
86    }
87
88    /// Add a new event from the given draft.
89    ///
90    /// # Errors
91    /// If the event is not found, database or file system access fails.
92    pub async fn new_event(
93        &self,
94        draft: EventDraft,
95    ) -> Result<impl Event + 'static, Box<dyn Error>> {
96        let uid = self.generate_uid(Kind::Event).await?;
97        let event = draft.resolve(&self.now).into_ics(&uid);
98        let path = self.get_path(&uid);
99
100        let calendar = Calendar::new().push(event.clone()).done();
101        fs::write(&path, calendar.to_string())
102            .await
103            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
104
105        self.db.upsert_event(&path, &event).await?;
106
107        let todo = self.short_ids.event(event).await?;
108        Ok(todo)
109    }
110
111    /// Upsert an event into the calendar.
112    ///
113    /// # Errors
114    /// If the event is not found, database or file system access fails.
115    pub async fn update_event(
116        &self,
117        id: &Id,
118        patch: EventPatch,
119    ) -> Result<impl Event + 'static, Box<dyn Error>> {
120        let uid = self.short_ids.get_uid(id).await?;
121        let Some(event) = self.db.events.get(&uid).await? else {
122            return Err("Event not found".into());
123        };
124
125        let path: PathBuf = event.path().into();
126        let mut calendar = parse_ics(&path).await?;
127        let e = calendar
128            .components
129            .iter_mut()
130            .filter_map(|a| match a {
131                CalendarComponent::Event(a) => Some(a),
132                _ => None,
133            })
134            .find(|a| a.get_uid() == Some(event.uid()))
135            .ok_or("Event not found in calendar")?;
136
137        patch.resolve(self.now).apply_to(e);
138        let event = e.clone();
139        fs::write(&path, calendar.done().to_string())
140            .await
141            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
142
143        self.db.upsert_event(&path, &event).await?;
144
145        let todo = self.short_ids.event(event).await?;
146        Ok(todo)
147    }
148
149    /// Get the kind of the given id, which can be either an event or a todo.
150    ///
151    /// # Errors
152    /// If the id is not found or database access fails.
153    pub async fn get_kind(&self, id: &Id) -> Result<Kind, Box<dyn Error>> {
154        tracing::debug!(?id, "getting kind of id");
155        if let Some(data) = self.short_ids.get(id).await? {
156            return Ok(data.kind);
157        }
158
159        let uid = id.as_uid();
160
161        tracing::debug!(uid, "checking if id is an event");
162        if self.db.events.get(uid).await?.is_some() {
163            return Ok(Kind::Event);
164        }
165
166        tracing::debug!(uid, "checking if id is a todo");
167        if self.db.todos.get(uid).await?.is_some() {
168            return Ok(Kind::Todo);
169        }
170
171        Err("Id not found".into())
172    }
173
174    /// List events matching the given conditions.
175    ///
176    /// # Errors
177    /// If database access fails.
178    pub async fn list_events(
179        &self,
180        conds: &EventConditions,
181        pager: &Pager,
182    ) -> Result<Vec<impl Event + 'static>, Box<dyn Error>> {
183        let conds = conds.resolve(&self.now);
184        let events = self.db.events.list(&conds, pager).await?;
185        let events = self.short_ids.events(events).await?;
186        Ok(events)
187    }
188
189    /// Counts the number of events matching the given conditions.
190    ///
191    /// # Errors
192    /// If database access fails.
193    pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, sqlx::Error> {
194        let conds = conds.resolve(&self.now);
195        self.db.events.count(&conds).await
196    }
197
198    /// Create a default todo draft based on the AIM configuration.
199    #[must_use]
200    pub fn default_todo_draft(&self) -> TodoDraft {
201        TodoDraft::default(&self.config, &self.now)
202    }
203
204    /// Add a new todo from the given draft.
205    ///
206    /// # Errors
207    /// If the todo is not found, database or file system access fails.
208    pub async fn new_todo(&self, draft: TodoDraft) -> Result<impl Todo + 'static, Box<dyn Error>> {
209        let uid = self.generate_uid(Kind::Todo).await?;
210        let todo = draft.resolve(&self.config, &self.now).into_ics(&uid);
211        let path = self.get_path(&uid);
212
213        let calendar = Calendar::new().push(todo.clone()).done();
214        fs::write(&path, calendar.to_string())
215            .await
216            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
217
218        self.db.upsert_todo(&path, &todo).await?;
219
220        let todo = self.short_ids.todo(todo).await?;
221        Ok(todo)
222    }
223
224    /// Upsert an event into the calendar.
225    ///
226    /// # Errors
227    /// If the todo is not found, database or file system access fails.
228    pub async fn update_todo(
229        &self,
230        id: &Id,
231        patch: TodoPatch,
232    ) -> Result<impl Todo + 'static, Box<dyn Error>> {
233        let uid = self.short_ids.get_uid(id).await?;
234        let Some(todo) = self.db.todos.get(&uid).await? else {
235            return Err("Todo not found".into());
236        };
237
238        let path: PathBuf = todo.path().into();
239        let mut calendar = parse_ics(&path).await?;
240        let t = calendar
241            .components
242            .iter_mut()
243            .filter_map(|a| match a {
244                CalendarComponent::Todo(a) => Some(a),
245                _ => None,
246            })
247            .find(|a| a.get_uid() == Some(todo.uid()))
248            .ok_or("Todo not found in calendar")?;
249
250        patch.resolve(&self.now).apply_to(t);
251        let todo = t.clone();
252        fs::write(&path, calendar.done().to_string())
253            .await
254            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
255
256        self.db.upsert_todo(&path, &todo).await?;
257
258        let todo = self.short_ids.todo(todo).await?;
259        Ok(todo)
260    }
261
262    /// Get a todo by its id.
263    ///
264    /// # Errors
265    /// If the todo is not found or database access fails.
266    pub async fn get_todo(&self, id: &Id) -> Result<impl Todo + 'static, Box<dyn Error>> {
267        let uid = self.short_ids.get_uid(id).await?;
268        match self.db.todos.get(&uid).await {
269            Ok(Some(todo)) => Ok(self.short_ids.todo(todo).await?),
270            Ok(None) => Err("Event not found".into()),
271            Err(e) => Err(e.into()),
272        }
273    }
274
275    /// List todos matching the given conditions, sorted and paginated.
276    ///
277    /// # Errors
278    /// If database access fails.
279    pub async fn list_todos(
280        &self,
281        conds: &TodoConditions,
282        sort: &[TodoSort],
283        pager: &Pager,
284    ) -> Result<Vec<impl Todo + 'static>, Box<dyn Error>> {
285        let conds = conds.resolve(&self.now);
286        let sort = TodoSort::resolve_vec(sort, &self.config);
287        let todos = self.db.todos.list(&conds, &sort, pager).await?;
288        let todos = self.short_ids.todos(todos).await?;
289        Ok(todos)
290    }
291
292    /// Counts the number of todos matching the given conditions.
293    ///
294    /// # Errors
295    /// If database access fails.
296    pub async fn count_todos(&self, conds: &TodoConditions) -> Result<i64, sqlx::Error> {
297        let conds = conds.resolve(&self.now);
298        self.db.todos.count(&conds).await
299    }
300
301    /// Flush the short IDs to remove all entries.
302    ///
303    /// # Errors
304    /// If database access fails.
305    pub async fn flush_short_ids(&self) -> Result<(), Box<dyn Error>> {
306        self.short_ids.flush().await
307    }
308
309    /// Close the AIM instance, saving any changes to the database.
310    ///
311    /// # Errors
312    /// If closing the database fails.
313    pub async fn close(self) -> Result<(), Box<dyn Error>> {
314        self.db.close().await
315    }
316
317    async fn generate_uid(&self, kind: Kind) -> Result<String, Box<dyn Error>> {
318        for i in 0..16 {
319            let uid = Uuid::new_v4().to_string(); // TODO: better uid
320            tracing::debug!(
321                ?uid,
322                attempt = i + 1,
323                "generated uid, checking for uniqueness"
324            );
325
326            let exists = match kind {
327                Kind::Event => self.db.events.get(&uid).await?.is_some(),
328                Kind::Todo => self.db.todos.get(&uid).await?.is_some(),
329            };
330            if exists {
331                tracing::debug!(uid, ?kind, "uid already exists in db");
332                continue;
333            }
334
335            let path = self.get_path(&uid);
336            if fs::try_exists(&path).await? {
337                tracing::debug!(uid, ?path, "uid already exists as a file");
338                continue;
339            }
340            return Ok(uid);
341        }
342
343        tracing::warn!("failed to generate a unique uid after multiple attempts");
344        Err("Failed to generate a unique UID after multiple attempts".into())
345    }
346
347    fn get_path(&self, uid: &str) -> PathBuf {
348        self.calendar_path.join(format!("{uid}.ics"))
349    }
350}
351
352async fn prepare(config: &Config) -> Result<(), Box<dyn Error>> {
353    if let Some(parent) = &config.state_dir {
354        tracing::debug!(path = %parent.display(), "ensuring state directory exists");
355        fs::create_dir_all(parent).await?;
356    }
357    Ok(())
358}
359
360async fn initialize_db(config: &Config) -> Result<LocalDb, Box<dyn Error>> {
361    const NAME: &str = "aim.db";
362    let db = if let Some(parent) = &config.state_dir {
363        LocalDb::open(Some(&parent.join(NAME))).await
364    } else {
365        LocalDb::open(None).await
366    }
367    .map_err(|e| format!("Failed to initialize db: {e}"))?;
368
369    Ok(db)
370}