aimcal_core/
aim.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use crate::{
6    Event, EventConditions, Pager, Priority, Todo, TodoConditions, TodoDraft, TodoSort,
7    event::ParsedEventConditions,
8    localdb::LocalDb,
9    short_id::ShortIds,
10    todo::{ParsedTodoConditions, ParsedTodoSort, TodoPatch},
11};
12use chrono::{DateTime, Duration, Local};
13use icalendar::{Calendar, CalendarComponent, Component};
14use std::{
15    error::Error,
16    path::{Path, PathBuf},
17};
18use tokio::fs;
19use uuid::Uuid;
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    pub async fn new(config: Config) -> Result<Self, Box<dyn Error>> {
34        let now = Local::now();
35
36        if let Some(parent) = &config.state_dir {
37            log::info!("Ensuring state directory exists: {}", parent.display());
38            fs::create_dir_all(parent).await?;
39        }
40
41        let db = LocalDb::open(&config.state_dir)
42            .await
43            .map_err(|e| format!("Failed to initialize db: {e}"))?;
44
45        let short_ids = ShortIds::new(db.clone());
46        let calendar_path = config.calendar_path.clone();
47        let that = Self {
48            now,
49            config,
50            db,
51            short_ids,
52            calendar_path,
53        };
54        that.add_calendar(&that.calendar_path)
55            .await
56            .map_err(|e| format!("Failed to add calendar files: {e}"))?;
57
58        Ok(that)
59    }
60
61    /// Returns the current time in the AIM instance.
62    pub fn now(&self) -> DateTime<Local> {
63        self.now
64    }
65
66    /// Refresh the current time to now.
67    pub fn refresh_now(&mut self) {
68        self.now = Local::now();
69    }
70
71    /// List events matching the given conditions.
72    pub async fn list_events(
73        &self,
74        conds: &EventConditions,
75        pager: &Pager,
76    ) -> Result<Vec<impl Event>, Box<dyn Error>> {
77        let conds = ParsedEventConditions::parse(&self.now, conds);
78        let events = self.db.events.list(&conds, pager).await?;
79        let events = self.short_ids.events(events).await?;
80        Ok(events)
81    }
82
83    /// Counts the number of events matching the given conditions.
84    pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, sqlx::Error> {
85        let conds = ParsedEventConditions::parse(&self.now, conds);
86        self.db.events.count(&conds).await
87    }
88
89    /// Create a default todo draft based on the AIM configuration.
90    pub fn default_todo_draft(&self) -> TodoDraft {
91        TodoDraft::default(&self.config)
92    }
93
94    /// Add a new todo from the given draft.
95    pub async fn new_todo(&self, draft: TodoDraft) -> Result<impl Todo, Box<dyn Error>> {
96        let uid = self.generate_uid().await?;
97        let todo = draft.into_todo(&self.config, self.now, &uid);
98        let path = self.get_path(&uid);
99
100        let calendar = Calendar::new().push(todo.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_todo(&path, &todo).await?;
106
107        let todo = self.short_ids.todo(todo).await?;
108        Ok(todo)
109    }
110
111    /// Upsert an event into the calendar.
112    pub async fn update_todo(
113        &self,
114        id: &Id,
115        patch: TodoPatch,
116    ) -> Result<impl Todo, Box<dyn Error>> {
117        let uid = self.short_ids.get_uid(id).await?;
118        let todo = match self.db.todos.get(&uid).await? {
119            Some(todo) => todo,
120            None => return Err("Todo not found".into()),
121        };
122
123        let path: PathBuf = todo.path().into();
124        let mut calendar = parse_ics(&path).await?;
125        let t = calendar
126            .components
127            .iter_mut()
128            .filter_map(|a| match a {
129                CalendarComponent::Todo(a) => Some(a),
130                _ => None,
131            })
132            .find(|a| a.get_uid() == Some(todo.uid()))
133            .ok_or("Todo not found in calendar")?;
134
135        patch.apply_to(t);
136        let todo = t.clone();
137        fs::write(&path, calendar.done().to_string())
138            .await
139            .map_err(|e| format!("Failed to write calendar file: {e}"))?;
140
141        self.db.upsert_todo(&path, &todo).await?;
142
143        let todo = self.short_ids.todo(todo).await?;
144        Ok(todo)
145    }
146
147    /// Get a todo by its UID.
148    pub async fn get_todo(&self, id: &Id) -> Result<Option<impl Todo>, Box<dyn Error>> {
149        let uid = self.short_ids.get_uid(id).await?;
150        match self.db.todos.get(&uid).await {
151            Ok(Some(todo)) => {
152                let todo = self.short_ids.todo(todo).await?;
153                Ok(Some(todo))
154            }
155            Ok(None) => Ok(None),
156            Err(e) => Err(e.into()),
157        }
158    }
159
160    /// List todos matching the given conditions, sorted and paginated.
161    pub async fn list_todos(
162        &self,
163        conds: &TodoConditions,
164        sort: &[TodoSort],
165        pager: &Pager,
166    ) -> Result<Vec<impl Todo>, Box<dyn Error>> {
167        let conds = ParsedTodoConditions::parse(&self.now, conds);
168
169        let sort: Vec<_> = sort
170            .iter()
171            .map(|s| ParsedTodoSort::parse(&self.config, *s))
172            .collect();
173
174        let todos = self.db.todos.list(&conds, &sort, pager).await?;
175        let todos = self.short_ids.todos(todos).await?;
176        Ok(todos)
177    }
178
179    /// Counts the number of todos matching the given conditions.
180    pub async fn count_todos(&self, conds: &TodoConditions) -> Result<i64, sqlx::Error> {
181        let conds = ParsedTodoConditions::parse(&self.now, conds);
182        self.db.todos.count(&conds).await
183    }
184
185    /// Close the AIM instance, saving any changes to the database.
186    pub async fn close(self) -> Result<(), Box<dyn Error>> {
187        self.db.close().await?;
188        Ok(())
189    }
190
191    async fn add_calendar(&self, calendar_path: &PathBuf) -> Result<(), Box<dyn Error>> {
192        let mut reader = fs::read_dir(calendar_path)
193            .await
194            .map_err(|e| format!("Failed to read directory: {e}"))?;
195
196        let mut handles = vec![];
197        let mut count_ics = 0;
198
199        while let Some(entry) = reader.next_entry().await? {
200            let path = entry.path();
201            match path.extension() {
202                Some(ext) if ext == "ics" => {
203                    count_ics += 1;
204                    let that = self.clone();
205                    handles.push(tokio::spawn(async move {
206                        if let Err(e) = that.add_ics(&path).await {
207                            log::error!("Failed to process file {}: {}", path.display(), e);
208                        }
209                    }));
210                }
211                _ => {}
212            }
213        }
214
215        for handle in handles {
216            handle.await?;
217        }
218
219        log::debug!("Total .ics files processed: {count_ics}");
220        Ok(())
221    }
222
223    async fn add_ics(self, path: &Path) -> Result<(), Box<dyn Error>> {
224        log::debug!("Parsing file: {}", path.display());
225        let calendar = parse_ics(path).await?;
226        log::debug!(
227            "Found {} components in {}.",
228            calendar.components.len(),
229            path.display()
230        );
231
232        for component in calendar.components {
233            log::debug!("Processing component: {component:?}");
234            match component {
235                CalendarComponent::Event(event) => self.db.upsert_event(path, &event).await?,
236                CalendarComponent::Todo(todo) => self.db.upsert_todo(path, &todo).await?,
237                _ => log::warn!("Ignoring unsupported component type: {component:?}"),
238            }
239        }
240
241        Ok(())
242    }
243
244    async fn generate_uid(&self) -> Result<String, Box<dyn Error>> {
245        for _ in 0..16 {
246            let uid = Uuid::new_v4().to_string(); // TODO: better uid
247            if self.db.todos.get(&uid).await?.is_some()
248                || fs::try_exists(&self.get_path(&uid)).await?
249            {
250                continue;
251            }
252            return Ok(uid);
253        }
254
255        Err("Failed to generate a unique UID after multiple attempts".into())
256    }
257
258    fn get_path(&self, uid: &str) -> PathBuf {
259        self.calendar_path.join(format!("{uid}.ics"))
260    }
261}
262
263/// Configuration for the AIM application.
264#[derive(Debug, Clone)]
265pub struct Config {
266    /// Path to the calendar directory.
267    pub calendar_path: PathBuf,
268
269    /// Directory for storing application state.
270    pub state_dir: Option<PathBuf>,
271
272    /// Default due time for new tasks.
273    pub default_due: Option<Duration>,
274
275    /// Default priority for new tasks.
276    pub default_priority: Priority,
277
278    /// If true, items with no priority will be listed first.
279    pub default_priority_none_fist: bool,
280}
281
282async fn parse_ics(path: &Path) -> Result<Calendar, Box<dyn Error>> {
283    fs::read_to_string(path)
284        .await
285        .map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?
286        .parse()
287        .map_err(|e| format!("Failed to parse calendar: {e}").into())
288}
289
290/// The unique identifier for a todo item, which can be either a UID or a short ID.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum Id {
293    /// The unique identifier for the todo item.
294    Uid(String),
295    /// Either a short identifier or a unique identifier.
296    ShortIdOrUid(String),
297}