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 aimcal_ical::{CalendarComponent, ICalendar};
9use jiff::Zoned;
10use tokio::fs;
11use uuid::Uuid;
12
13use crate::io::{add_calendar, parse_ics, write_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: Zoned,
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 = Zoned::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) -> Zoned {
61        self.now.clone()
62    }
63
64    /// Refresh the current time to now.
65    pub fn refresh_now(&mut self) {
66        self.now = Zoned::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        // Create calendar with single event
101        // TODO: consider reusing existing calendar if possible. see also: Todo
102        let mut calendar = ICalendar::new();
103        calendar.components.push(event.clone().into());
104
105        write_ics(&path, &calendar).await?;
106        self.db.upsert_event(&path, &event).await?;
107
108        let event = self.short_ids.event(event).await?;
109        Ok(event)
110    }
111
112    /// Upsert an event into the calendar.
113    ///
114    /// # Errors
115    /// If the event is not found, database or file system access fails.
116    pub async fn update_event(
117        &self,
118        id: &Id,
119        patch: EventPatch,
120    ) -> Result<impl Event + 'static, Box<dyn Error>> {
121        let uid = self.short_ids.get_uid(id).await?;
122        let Some(event) = self.db.events.get(&uid).await? else {
123            return Err("Event not found".into());
124        };
125
126        let path: PathBuf = event.path().into();
127        let mut calendar = parse_ics(&path).await?;
128
129        // Find and update the event by UID
130        let mut found = false;
131        for component in &mut calendar.components {
132            if let CalendarComponent::Event(e) = component
133                && e.uid.content.to_string() == event.uid()
134            // PERF: avoid to_string() here
135            {
136                patch.resolve(self.now.clone()).apply_to(e);
137                found = true;
138                break;
139            }
140        }
141
142        if !found {
143            return Err("Event not found in calendar".into());
144        }
145
146        write_ics(&path, &calendar).await?;
147        self.db.upsert_event(&path, &event).await?;
148
149        let event_with_id = self.short_ids.event(event).await?;
150        Ok(event_with_id)
151    }
152
153    /// Get the kind of the given id, which can be either an event or a todo.
154    ///
155    /// # Errors
156    /// If the id is not found or database access fails.
157    pub async fn get_kind(&self, id: &Id) -> Result<Kind, Box<dyn Error>> {
158        tracing::debug!(?id, "getting kind of id");
159        if let Some(data) = self.short_ids.get(id).await? {
160            return Ok(data.kind);
161        }
162
163        let uid = id.as_uid();
164
165        tracing::debug!(uid, "checking if id is an event");
166        if self.db.events.get(uid).await?.is_some() {
167            return Ok(Kind::Event);
168        }
169
170        tracing::debug!(uid, "checking if id is a todo");
171        if self.db.todos.get(uid).await?.is_some() {
172            return Ok(Kind::Todo);
173        }
174
175        Err("Id not found".into())
176    }
177
178    /// List events matching the given conditions.
179    ///
180    /// # Errors
181    /// If database access fails.
182    pub async fn list_events(
183        &self,
184        conds: &EventConditions,
185        pager: &Pager,
186    ) -> Result<Vec<impl Event + 'static>, Box<dyn Error>> {
187        let conds = conds.resolve(&self.now)?;
188        let events = self.db.events.list(&conds, pager).await?;
189        let events = self.short_ids.events(events).await?;
190        Ok(events)
191    }
192
193    /// Counts the number of events matching the given conditions.
194    ///
195    /// # Errors
196    /// If database access fails.
197    pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, Box<dyn Error>> {
198        let conds = conds.resolve(&self.now)?;
199        Ok(self.db.events.count(&conds).await?)
200    }
201
202    /// Create a default todo draft based on the AIM configuration.
203    ///
204    /// # Errors
205    /// If date/time resolution fails.
206    pub fn default_todo_draft(&self) -> Result<TodoDraft, String> {
207        TodoDraft::default(&self.config, &self.now)
208    }
209
210    /// Add a new todo from the given draft.
211    ///
212    /// # Errors
213    /// If the todo is not found, database or file system access fails.
214    pub async fn new_todo(&self, draft: TodoDraft) -> Result<impl Todo + 'static, Box<dyn Error>> {
215        let uid = self.generate_uid(Kind::Todo).await?;
216        let todo = draft.resolve(&self.config, &self.now).into_ics(&uid);
217        let path = self.get_path(&uid);
218
219        // Create calendar with single todo
220        let mut calendar = ICalendar::new();
221        calendar.components.push(todo.clone().into());
222
223        write_ics(&path, &calendar).await?;
224        self.db.upsert_todo(&path, &todo).await?;
225
226        let todo_with_id = self.short_ids.todo(todo).await?;
227        Ok(todo_with_id)
228    }
229
230    /// Upsert an event into the calendar.
231    ///
232    /// # Errors
233    /// If the todo is not found, database or file system access fails.
234    pub async fn update_todo(
235        &self,
236        id: &Id,
237        patch: TodoPatch,
238    ) -> Result<impl Todo + 'static, Box<dyn Error>> {
239        let uid = self.short_ids.get_uid(id).await?;
240        let Some(todo) = self.db.todos.get(&uid).await? else {
241            return Err("Todo not found".into());
242        };
243
244        let path: PathBuf = todo.path().into();
245        let mut calendar = parse_ics(&path).await?;
246
247        // Find and update the todo by UID
248        let mut found = false;
249        for component in &mut calendar.components {
250            if let CalendarComponent::Todo(t) = component
251                && t.uid.content.to_string() == todo.uid()
252            {
253                patch.resolve(&self.now).apply_to(t);
254                found = true;
255                break;
256            }
257        }
258
259        if !found {
260            return Err("Todo not found in calendar".into());
261        }
262
263        write_ics(&path, &calendar).await?;
264        self.db.upsert_todo(&path, &todo).await?;
265
266        let todo = self.short_ids.todo(todo).await?;
267        Ok(todo)
268    }
269
270    /// Get a todo by its id.
271    ///
272    /// # Errors
273    /// If the todo is not found or database access fails.
274    pub async fn get_todo(&self, id: &Id) -> Result<impl Todo + 'static, Box<dyn Error>> {
275        let uid = self.short_ids.get_uid(id).await?;
276        match self.db.todos.get(&uid).await {
277            Ok(Some(todo)) => Ok(self.short_ids.todo(todo).await?),
278            Ok(None) => Err("Event not found".into()),
279            Err(e) => Err(e.into()),
280        }
281    }
282
283    /// List todos matching the given conditions, sorted and paginated.
284    ///
285    /// # Errors
286    /// If database access fails.
287    pub async fn list_todos(
288        &self,
289        conds: &TodoConditions,
290        sort: &[TodoSort],
291        pager: &Pager,
292    ) -> Result<Vec<impl Todo + 'static>, Box<dyn Error>> {
293        let conds = conds.resolve(&self.now)?;
294        let sort = TodoSort::resolve_vec(sort, &self.config);
295        let todos = self.db.todos.list(&conds, &sort, pager).await?;
296        let todos = self.short_ids.todos(todos).await?;
297        Ok(todos)
298    }
299
300    /// Counts the number of todos matching the given conditions.
301    ///
302    /// # Errors
303    /// If database access fails.
304    pub async fn count_todos(&self, conds: &TodoConditions) -> Result<i64, Box<dyn Error>> {
305        let conds = conds.resolve(&self.now)?;
306        Ok(self.db.todos.count(&conds).await?)
307    }
308
309    /// Flush the short IDs to remove all entries.
310    ///
311    /// # Errors
312    /// If database access fails.
313    pub async fn flush_short_ids(&self) -> Result<(), Box<dyn Error>> {
314        self.short_ids.flush().await
315    }
316
317    /// Close the AIM instance, saving any changes to the database.
318    ///
319    /// # Errors
320    /// If closing the database fails.
321    pub async fn close(self) -> Result<(), Box<dyn Error>> {
322        self.db.close().await
323    }
324
325    async fn generate_uid(&self, kind: Kind) -> Result<String, Box<dyn Error>> {
326        for i in 0..16 {
327            let uid = Uuid::new_v4().to_string(); // TODO: better uid
328            tracing::debug!(
329                ?uid,
330                attempt = i + 1,
331                "generated uid, checking for uniqueness"
332            );
333
334            let exists = match kind {
335                Kind::Event => self.db.events.get(&uid).await?.is_some(),
336                Kind::Todo => self.db.todos.get(&uid).await?.is_some(),
337            };
338            if exists {
339                tracing::debug!(uid, ?kind, "uid already exists in db");
340                continue;
341            }
342
343            let path = self.get_path(&uid);
344            if fs::try_exists(&path).await? {
345                tracing::debug!(uid, ?path, "uid already exists as a file");
346                continue;
347            }
348            return Ok(uid);
349        }
350
351        tracing::warn!("failed to generate a unique uid after multiple attempts");
352        Err("Failed to generate a unique UID after multiple attempts".into())
353    }
354
355    fn get_path(&self, uid: &str) -> PathBuf {
356        self.calendar_path.join(format!("{uid}.ics"))
357    }
358}
359
360async fn prepare(config: &Config) -> Result<(), Box<dyn Error>> {
361    if let Some(parent) = &config.state_dir {
362        tracing::debug!(path = %parent.display(), "ensuring state directory exists");
363        fs::create_dir_all(parent).await?;
364    }
365    Ok(())
366}
367
368async fn initialize_db(config: &Config) -> Result<LocalDb, Box<dyn Error>> {
369    const NAME: &str = "aim.db";
370    let db = if let Some(parent) = &config.state_dir {
371        LocalDb::open(Some(&parent.join(NAME))).await
372    } else {
373        LocalDb::open(None).await
374    }
375    .map_err(|e| format!("Failed to initialize db: {e}"))?;
376
377    Ok(db)
378}