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, Kind, 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    /// 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    /// Create a default event draft based on the AIM configuration.
71    pub fn default_event_draft(&self) -> EventDraft {
72        EventDraft::default(self.now)
73    }
74
75    /// Add a new event from the given draft.
76    pub async fn new_event(
77        &self,
78        draft: EventDraft,
79    ) -> Result<impl Event + 'static, Box<dyn Error>> {
80        let uid = self.generate_uid(Kind::Event).await?;
81        let event = draft.into_ics(&self.now, &uid);
82        let path = self.get_path(&uid);
83
84        let calendar = Calendar::new().push(event.clone()).done();
85        fs::write(&path, calendar.to_string())
86            .await
87            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
88
89        self.db.upsert_event(&path, &event).await?;
90
91        let todo = self.short_ids.event(event).await?;
92        Ok(todo)
93    }
94
95    /// Upsert an event into the calendar.
96    pub async fn update_event(
97        &self,
98        id: &Id,
99        patch: EventPatch,
100    ) -> Result<impl Event + 'static, Box<dyn Error>> {
101        let uid = self.short_ids.get_uid(id).await?;
102        let event = match self.db.events.get(&uid).await? {
103            Some(todo) => todo,
104            None => return Err("Todo not found".into()),
105        };
106
107        let path: PathBuf = event.path().into();
108        let mut calendar = parse_ics(&path).await?;
109        let t = calendar
110            .components
111            .iter_mut()
112            .filter_map(|a| match a {
113                CalendarComponent::Event(a) => Some(a),
114                _ => None,
115            })
116            .find(|a| a.get_uid() == Some(event.uid()))
117            .ok_or("Event not found in calendar")?;
118
119        patch.apply_to(t);
120        let todo = t.clone();
121        fs::write(&path, calendar.done().to_string())
122            .await
123            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
124
125        self.db.upsert_event(&path, &todo).await?;
126
127        let todo = self.short_ids.event(todo).await?;
128        Ok(todo)
129    }
130
131    /// Get the kind of the given id, which can be either an event or a todo.
132    pub async fn get_kind(&self, id: &Id) -> Result<Kind, Box<dyn Error>> {
133        tracing::debug!(?id, "getting kind of id");
134        if let Some(data) = self.short_ids.get(id).await? {
135            return Ok(data.kind);
136        }
137
138        let uid = id.as_uid();
139
140        tracing::debug!(uid, "checking if id is an event");
141        if self.db.events.get(uid).await?.is_some() {
142            return Ok(Kind::Event);
143        }
144
145        tracing::debug!(uid, "checking if id is a todo");
146        if self.db.todos.get(uid).await?.is_some() {
147            return Ok(Kind::Todo);
148        }
149
150        Err("Id not found".into())
151    }
152
153    /// Get a event by its id.
154    pub async fn get_event(&self, id: &Id) -> Result<impl Event + 'static, Box<dyn Error>> {
155        let uid = self.short_ids.get_uid(id).await?;
156        match self.db.events.get(&uid).await {
157            Ok(Some(event)) => Ok(self.short_ids.event(event).await?),
158            Ok(None) => Err("Event not found".into()),
159            Err(e) => Err(e.into()),
160        }
161    }
162
163    /// List events matching the given conditions.
164    pub async fn list_events(
165        &self,
166        conds: &EventConditions,
167        pager: &Pager,
168    ) -> Result<Vec<impl Event + 'static>, Box<dyn Error>> {
169        let conds = ParsedEventConditions::parse(&self.now, conds);
170        let events = self.db.events.list(&conds, pager).await?;
171        let events = self.short_ids.events(events).await?;
172        Ok(events)
173    }
174
175    /// Counts the number of events matching the given conditions.
176    pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, sqlx::Error> {
177        let conds = ParsedEventConditions::parse(&self.now, conds);
178        self.db.events.count(&conds).await
179    }
180
181    /// Create a default todo draft based on the AIM configuration.
182    pub fn default_todo_draft(&self) -> TodoDraft {
183        TodoDraft::default(&self.config, &self.now)
184    }
185
186    /// Add a new todo from the given draft.
187    pub async fn new_todo(&self, draft: TodoDraft) -> Result<impl Todo + 'static, Box<dyn Error>> {
188        let uid = self.generate_uid(Kind::Todo).await?;
189        let todo = draft.into_ics(&self.config, &self.now, &uid);
190        let path = self.get_path(&uid);
191
192        let calendar = Calendar::new().push(todo.clone()).done();
193        fs::write(&path, calendar.to_string())
194            .await
195            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
196
197        self.db.upsert_todo(&path, &todo).await?;
198
199        let todo = self.short_ids.todo(todo).await?;
200        Ok(todo)
201    }
202
203    /// Upsert an event into the calendar.
204    pub async fn update_todo(
205        &self,
206        id: &Id,
207        patch: TodoPatch,
208    ) -> Result<impl Todo + 'static, Box<dyn Error>> {
209        let uid = self.short_ids.get_uid(id).await?;
210        let todo = match self.db.todos.get(&uid).await? {
211            Some(todo) => todo,
212            None => return Err("Todo not found".into()),
213        };
214
215        let path: PathBuf = todo.path().into();
216        let mut calendar = parse_ics(&path).await?;
217        let t = calendar
218            .components
219            .iter_mut()
220            .filter_map(|a| match a {
221                CalendarComponent::Todo(a) => Some(a),
222                _ => None,
223            })
224            .find(|a| a.get_uid() == Some(todo.uid()))
225            .ok_or("Todo not found in calendar")?;
226
227        patch.apply_to(&self.now, t);
228        let todo = t.clone();
229        fs::write(&path, calendar.done().to_string())
230            .await
231            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
232
233        self.db.upsert_todo(&path, &todo).await?;
234
235        let todo = self.short_ids.todo(todo).await?;
236        Ok(todo)
237    }
238
239    /// Get a todo by its id.
240    pub async fn get_todo(&self, id: &Id) -> Result<impl Todo + 'static, Box<dyn Error>> {
241        let uid = self.short_ids.get_uid(id).await?;
242        match self.db.todos.get(&uid).await {
243            Ok(Some(todo)) => Ok(self.short_ids.todo(todo).await?),
244            Ok(None) => Err("Event not found".into()),
245            Err(e) => Err(e.into()),
246        }
247    }
248
249    /// List todos matching the given conditions, sorted and paginated.
250    pub async fn list_todos(
251        &self,
252        conds: &TodoConditions,
253        sort: &[TodoSort],
254        pager: &Pager,
255    ) -> Result<Vec<impl Todo + 'static>, Box<dyn Error>> {
256        let conds = ParsedTodoConditions::parse(&self.now, conds);
257        let sort = ParsedTodoSort::parse_vec(&self.config, sort);
258        let todos = self.db.todos.list(&conds, &sort, pager).await?;
259        let todos = self.short_ids.todos(todos).await?;
260        Ok(todos)
261    }
262
263    /// Counts the number of todos matching the given conditions.
264    pub async fn count_todos(&self, conds: &TodoConditions) -> Result<i64, sqlx::Error> {
265        let conds = ParsedTodoConditions::parse(&self.now, conds);
266        self.db.todos.count(&conds).await
267    }
268
269    /// Flush the short IDs to remove all entries.
270    pub async fn flush_short_ids(&self) -> Result<(), Box<dyn Error>> {
271        self.short_ids.flush().await
272    }
273
274    /// Close the AIM instance, saving any changes to the database.
275    pub async fn close(self) -> Result<(), Box<dyn Error>> {
276        self.db.close().await
277    }
278
279    #[tracing::instrument(skip(self))]
280    async fn add_calendar(&self, calendar_path: &PathBuf) -> Result<(), Box<dyn Error>> {
281        let mut reader = fs::read_dir(calendar_path)
282            .await
283            .map_err(|e| format!("Failed to read directory: {e}"))?;
284
285        let mut handles = vec![];
286        let mut count_ics = 0;
287
288        while let Some(entry) = reader.next_entry().await? {
289            let path = entry.path();
290            match path.extension() {
291                Some(ext) if ext == "ics" => {
292                    count_ics += 1;
293                    let that = self.clone();
294                    handles.push(tokio::spawn(async move {
295                        if let Err(err) = that.add_ics(&path).await {
296                            tracing::error!(path = %path.display(), err, "failed to process file");
297                        }
298                    }));
299                }
300                _ => {}
301            }
302        }
303
304        for handle in handles {
305            handle.await?;
306        }
307
308        tracing::debug!(count = count_ics, "total .ics files processed");
309        Ok(())
310    }
311
312    async fn add_ics(self, path: &Path) -> Result<(), Box<dyn Error>> {
313        tracing::debug!(path = %path.display(), "parsing file");
314        let calendar = parse_ics(path).await?;
315
316        tracing::debug!(path = %path.display(), components = calendar.components.len(), "found components");
317        for component in calendar.components {
318            tracing::debug!(?component, "processing component");
319            match component {
320                CalendarComponent::Event(event) => self.db.upsert_event(path, &event).await?,
321                CalendarComponent::Todo(todo) => self.db.upsert_todo(path, &todo).await?,
322                _ => tracing::warn!(?component, "ignoring unsupported component type"),
323            }
324        }
325
326        Ok(())
327    }
328
329    async fn generate_uid(&self, kind: Kind) -> Result<String, Box<dyn Error>> {
330        for i in 0..16 {
331            let uid = Uuid::new_v4().to_string(); // TODO: better uid
332            tracing::debug!(
333                ?uid,
334                attempt = i + 1,
335                "generated uid, checking for uniqueness"
336            );
337
338            let exists = match kind {
339                Kind::Event => self.db.events.get(&uid).await?.is_some(),
340                Kind::Todo => self.db.todos.get(&uid).await?.is_some(),
341            };
342            if exists {
343                tracing::debug!(uid, ?kind, "uid already exists in db");
344                continue;
345            }
346
347            let path = self.get_path(&uid);
348            if fs::try_exists(&path).await? {
349                tracing::debug!(uid, ?path, "uid already exists as a file");
350                continue;
351            }
352            return Ok(uid);
353        }
354
355        tracing::warn!("failed to generate a unique uid after multiple attempts");
356        Err("Failed to generate a unique UID after multiple attempts".into())
357    }
358
359    fn get_path(&self, uid: &str) -> PathBuf {
360        self.calendar_path.join(format!("{uid}.ics"))
361    }
362}
363
364async fn prepare(config: &Config) -> Result<(), Box<dyn Error>> {
365    if let Some(parent) = &config.state_dir {
366        tracing::debug!(path = %parent.display(), "ensuring state directory exists");
367        fs::create_dir_all(parent).await?;
368    }
369    Ok(())
370}
371
372async fn parse_ics(path: &Path) -> Result<Calendar, Box<dyn Error>> {
373    fs::read_to_string(path)
374        .await
375        .map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?
376        .parse()
377        .map_err(|e| format!("Failed to parse calendar: {e}").into())
378}