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