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