1use std::collections::{HashMap, HashSet};
6use std::error::Error;
7use std::fmt;
8
9use jiff::Zoned;
10use tokio::fs;
11use uuid::Uuid;
12
13use crate::config::StoreDef;
14use crate::db::{Db, calendars::CalendarRecord};
15use crate::short_id::ShortIds;
16use crate::store::{CaldavStore, LocalStore, Store, SyncResult};
17use crate::{
18 Config, Event, EventConditions, EventDraft, EventPatch, Id, Kind, Pager, Todo, TodoConditions,
19 TodoDraft, TodoPatch, TodoSort,
20};
21
22#[derive(Debug, Clone, serde::Serialize)]
24pub struct CalendarDetails {
25 pub id: String,
27 pub name: String,
29 pub kind: String,
31 pub priority: i32,
33 pub enabled: bool,
35 pub is_default: bool,
37 pub created_at: String,
39 pub updated_at: String,
41 pub store: Option<CalendarStoreDetails>,
43}
44
45#[derive(Debug, Clone, serde::Serialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum CalendarStoreDetails {
49 Local {
51 calendar_path: Option<String>,
53 },
54 Caldav {
56 base_url: String,
58 calendar_home: String,
60 calendar_href: String,
62 auth_method: String,
64 timeout_secs: u64,
66 user_agent: String,
68 },
69}
70
71pub struct Aim {
73 now: Zoned,
74 config: Config,
75 db: Db,
76 short_ids: ShortIds,
77 stores: HashMap<String, Box<dyn Store>>,
78 default_calendar: String,
79 startup_notices: Vec<String>,
80}
81
82struct InitializedStores {
83 stores: HashMap<String, Box<dyn Store>>,
84 default_calendar: String,
85 startup_notices: Vec<String>,
86}
87
88impl fmt::Debug for Aim {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.debug_struct("Aim")
91 .field("now", &self.now)
92 .field("config", &self.config)
93 .field("db", &self.db)
94 .field("short_ids", &self.short_ids)
95 .field("stores", &self.stores.len())
96 .field("default_calendar", &self.default_calendar)
97 .field("startup_notices", &self.startup_notices)
98 .finish()
99 }
100}
101
102impl Aim {
103 fn calendar_store_details(
104 entry: &crate::CalendarEntry,
105 backend: &StoreDef,
106 ) -> CalendarStoreDetails {
107 match backend {
108 StoreDef::Local { .. } => CalendarStoreDetails::Local {
109 calendar_path: entry.calendar_path.clone(),
110 },
111 StoreDef::Caldav {
112 base_url,
113 calendar_home,
114 auth,
115 timeout_secs,
116 user_agent,
117 } => CalendarStoreDetails::Caldav {
118 base_url: base_url.clone(),
119 calendar_home: calendar_home.clone(),
120 calendar_href: entry.calendar_href.clone().unwrap_or_default(),
121 auth_method: match auth {
122 crate::AuthMethod::None => "none".to_string(),
123 crate::AuthMethod::Basic { .. } => "basic".to_string(),
124 crate::AuthMethod::Bearer { .. } => "bearer".to_string(),
125 },
126 timeout_secs: *timeout_secs,
127 user_agent: user_agent.clone(),
128 },
129 }
130 }
131
132 fn create_store(
134 calendar_id: String,
135 entry: &crate::CalendarEntry,
136 store_def: &StoreDef,
137 db: &Db,
138 state_dir: Option<&std::path::Path>,
139 ) -> Result<Box<dyn Store>, Box<dyn Error>> {
140 match store_def {
141 StoreDef::Local { .. } => {
142 let calendar_path = entry.calendar_path.as_ref().map_or_else(
143 || {
144 state_dir.map_or_else(
145 || std::path::PathBuf::from("calendar"),
146 |p| p.join("calendar"),
147 )
148 },
149 std::path::PathBuf::from,
150 );
151 Ok(Box::new(LocalStore::with_db(
152 calendar_path,
153 db.clone(),
154 calendar_id,
155 )))
156 }
157 StoreDef::Caldav {
158 base_url,
159 calendar_home,
160 auth,
161 timeout_secs,
162 user_agent,
163 } => {
164 let calendar_href = entry.calendar_href.as_deref().ok_or_else(|| {
165 format!(
166 "Calendar '{calendar_id}' references caldav store but has no calendar_href"
167 )
168 })?;
169 let caldav_config = aimcal_caldav::CalDavConfig {
170 base_url: base_url.clone(),
171 calendar_home: calendar_home.clone(),
172 auth: auth.clone(),
173 timeout_secs: *timeout_secs,
174 user_agent: user_agent.clone(),
175 };
176 let backend = CaldavStore::new(
177 caldav_config,
178 calendar_href.to_string(),
179 db.clone(),
180 calendar_id,
181 )
182 .map_err(|e| format!("Failed to create CalDAV store: {e}"))?;
183 Ok(Box::new(backend))
184 }
185 }
186 }
187
188 fn get_store(&self, calendar_id: &str) -> Result<&dyn Store, Box<dyn Error>> {
190 self.stores
191 .get(calendar_id)
192 .map(Box::as_ref)
193 .ok_or_else(|| format!("Store not found for calendar: {calendar_id}").into())
194 }
195
196 pub async fn new(mut config: Config) -> Result<Self, Box<dyn Error>> {
201 let now = Zoned::now();
202
203 config.normalize()?;
204 prepare(&config).await?;
205
206 let db = initialize_db(&config).await?;
207 let short_ids = ShortIds::new(db.clone());
208
209 let InitializedStores {
211 stores,
212 default_calendar,
213 startup_notices,
214 } = if config.is_legacy_format() {
215 Self::initialize_legacy_calendar(&config, &db).await?
216 } else {
217 Self::initialize_multi_calendars(&config, &db).await?
218 };
219
220 for (calendar_id, backend) in &stores {
222 backend.sync_cache().await.map_err(|e| {
223 format!("Failed to sync store cache for calendar '{calendar_id}': {e}")
224 })?;
225 }
226
227 Ok(Self {
228 now,
229 config,
230 db,
231 short_ids,
232 stores,
233 default_calendar,
234 startup_notices,
235 })
236 }
237
238 async fn initialize_legacy_calendar(
239 config: &Config,
240 db: &Db,
241 ) -> Result<InitializedStores, Box<dyn Error>> {
242 let default_calendar_id = "default".to_string();
243
244 let calendar_path = config
246 .calendar_path
247 .as_ref()
248 .map(|p| p.to_string_lossy().to_string());
249
250 let entry = crate::CalendarEntry {
251 id: default_calendar_id.clone(),
252 name: "Default".to_string(),
253 store: "local".to_string(),
254 calendar_href: None,
255 calendar_path,
256 priority: 0,
257 enabled: true,
258 };
259 let store_def = StoreDef::Local {
260 calendar_path: None,
261 };
262
263 let backend = Self::create_store(
264 default_calendar_id.clone(),
265 &entry,
266 &store_def,
267 db,
268 config.state_dir.as_deref(),
269 )?;
270
271 let calendar = CalendarRecord::new(
272 default_calendar_id.clone(),
273 "Default".to_string(),
274 "local".to_string(),
275 0,
276 true,
277 );
278 db.calendars.upsert(calendar).await?;
279
280 let mut stores = HashMap::new();
281 stores.insert(default_calendar_id.clone(), backend);
282
283 Ok(InitializedStores {
284 stores,
285 default_calendar: default_calendar_id,
286 startup_notices: Vec::new(),
287 })
288 }
289
290 async fn initialize_multi_calendars(
291 config: &Config,
292 db: &Db,
293 ) -> Result<InitializedStores, Box<dyn Error>> {
294 if config.calendars.is_empty() {
295 return Err("No calendars configured".into());
296 }
297
298 let existing = db.calendars.list().await?;
299 let configured_ids: HashSet<_> = config
300 .calendars
301 .iter()
302 .map(|calendar| &calendar.id)
303 .collect();
304 let mut auto_disabled = Vec::new();
305 for calendar in existing {
306 if configured_ids.contains(&calendar.id) || !calendar.enabled {
307 continue;
308 }
309
310 db.calendars.set_enabled(&calendar.id, false).await?;
311 auto_disabled.push(calendar.id);
312 }
313
314 let mut effective = Vec::with_capacity(config.calendars.len());
315 for calendar in &config.calendars {
316 let store_def = config.stores.get(&calendar.store).ok_or_else(|| {
317 format!(
318 "Store '{}' not found for calendar '{}'",
319 calendar.store, calendar.id
320 )
321 })?;
322 let calendar_kind = match store_def {
323 StoreDef::Local { .. } => "local",
324 StoreDef::Caldav { .. } => "caldav",
325 };
326 let record = CalendarRecord::new(
327 calendar.id.clone(),
328 calendar.name.clone(),
329 calendar_kind.to_string(),
330 calendar.priority,
331 calendar.enabled,
332 );
333 db.calendars.upsert(record).await?;
334 effective.push((calendar, calendar.enabled));
335 }
336
337 let mut stores = HashMap::new();
338 for (calendar, enabled) in &effective {
339 if !enabled {
340 continue;
341 }
342
343 let store_def = config
344 .stores
345 .get(&calendar.store)
346 .ok_or_else(|| format!("Store '{}' not found", calendar.store))?;
347
348 let backend = Self::create_store(
349 calendar.id.clone(),
350 calendar,
351 store_def,
352 db,
353 config.state_dir.as_deref(),
354 )?;
355 stores.insert(calendar.id.clone(), backend);
356 }
357
358 if stores.is_empty() {
359 return Err("No enabled calendars found in configuration".into());
360 }
361
362 let default_calendar = if stores.contains_key(&config.default_calendar) {
363 config.default_calendar.clone()
364 } else {
365 config
366 .calendars
367 .iter()
368 .filter(|calendar| stores.contains_key(&calendar.id))
369 .min_by_key(|calendar| calendar.priority)
370 .map(|calendar| calendar.id.clone())
371 .ok_or("No enabled calendars found in configuration")?
372 };
373
374 let startup_notices = if auto_disabled.is_empty() {
375 Vec::new()
376 } else {
377 vec![format!(
378 "Disabled calendar(s) not present in config: {}. Existing data was kept.",
379 auto_disabled.join(", ")
380 )]
381 };
382
383 Ok(InitializedStores {
384 stores,
385 default_calendar,
386 startup_notices,
387 })
388 }
389
390 #[must_use]
392 pub fn now(&self) -> Zoned {
393 self.now.clone()
394 }
395
396 pub fn refresh_now(&mut self) {
398 self.now = Zoned::now();
399 }
400
401 #[must_use]
403 pub fn default_event_draft(&self) -> EventDraft {
404 EventDraft::default(&self.now)
405 }
406
407 pub async fn get_event(&self, id: &Id) -> Result<impl Event + 'static, Box<dyn Error>> {
412 let uid = self.short_ids.get_uid(id).await?;
413 match self.db.events.get(&uid).await {
414 Ok(Some(event)) => Ok(self.short_ids.event(event).await?),
415 Ok(None) => Err("Event not found".into()),
416 Err(e) => Err(e.into()),
417 }
418 }
419
420 pub async fn find_latest_event_by_summary(
425 &self,
426 summary: &str,
427 ) -> Result<Option<impl Event + 'static>, Box<dyn Error>> {
428 let Some(event) = self.db.events.find_latest_by_summary(summary).await? else {
429 return Ok(None);
430 };
431 let event = self.short_ids.event(event).await?;
432 Ok(Some(event))
433 }
434
435 pub async fn new_event(
440 &self,
441 draft: EventDraft,
442 ) -> Result<impl Event + 'static, Box<dyn Error>> {
443 let uid = self.generate_uid(Kind::Event).await?;
444 let event = draft.resolve(&self.now).into_ics(&uid);
445
446 let calendar_id = draft
448 .calendar_id
449 .as_deref()
450 .unwrap_or(&self.default_calendar);
451 let backend = self.get_store(calendar_id)?;
452
453 let resource_id = backend
455 .create_event(&uid, &event)
456 .await
457 .map_err(|e| format!("Failed to create event in store: {e}"))?;
458
459 self.db.upsert_event(&uid, &event, calendar_id).await?;
461 self.db
462 .resources
463 .insert(&uid, calendar_id, &resource_id, None)
464 .await?;
465
466 let event = self.short_ids.event(event).await?;
467 Ok(event)
468 }
469
470 pub async fn update_event(
475 &self,
476 id: &Id,
477 patch: EventPatch,
478 ) -> Result<impl Event + 'static, Box<dyn Error>> {
479 let uid = self.short_ids.get_uid(id).await?;
480 let Some(_event) = self.db.events.get(&uid).await? else {
481 return Err("Event not found".into());
482 };
483
484 let event_record = self.db.events.get(&uid).await?.ok_or("Event not found")?;
486 let backend = self.get_store(&event_record.calendar_id)?;
487 let calendar_id = backend.calendar_id();
488
489 let updated_event = backend
491 .update_event(&uid, &patch)
492 .await
493 .map_err(|e| format!("Failed to update event in store: {e}"))?;
494
495 self.db
497 .upsert_event(&uid, &updated_event, calendar_id)
498 .await?;
499
500 let event_with_id = self.short_ids.event(updated_event).await?;
501 Ok(event_with_id)
502 }
503
504 pub async fn get_kind(&self, id: &Id) -> Result<Kind, Box<dyn Error>> {
509 tracing::debug!(?id, "getting kind of id");
510 if let Some(data) = self.short_ids.get(id).await? {
511 return Ok(data.kind);
512 }
513
514 let uid = id.as_uid();
515
516 tracing::debug!(uid, "checking if id is an event");
517 if self.db.events.get(uid).await?.is_some() {
518 return Ok(Kind::Event);
519 }
520
521 tracing::debug!(uid, "checking if id is a todo");
522 if self.db.todos.get(uid).await?.is_some() {
523 return Ok(Kind::Todo);
524 }
525
526 Err("Id not found".into())
527 }
528
529 pub async fn list_events(
534 &self,
535 conds: &EventConditions,
536 pager: &Pager,
537 ) -> Result<Vec<impl Event + 'static>, Box<dyn Error>> {
538 let conds = conds.resolve(&self.now)?;
539 let events = self.db.events.list(&conds, pager).await?;
540 let events = self.short_ids.events(events).await?;
541 Ok(events)
542 }
543
544 pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, Box<dyn Error>> {
549 let conds = conds.resolve(&self.now)?;
550 Ok(self.db.events.count(&conds).await?)
551 }
552
553 pub fn default_todo_draft(&self) -> Result<TodoDraft, String> {
558 TodoDraft::default(&self.config, &self.now)
559 }
560
561 pub async fn find_latest_todo_by_summary(
566 &self,
567 summary: &str,
568 ) -> Result<Option<impl Todo + 'static>, Box<dyn Error>> {
569 let Some(todo) = self.db.todos.find_latest_by_summary(summary).await? else {
570 return Ok(None);
571 };
572 let todo = self.short_ids.todo(todo).await?;
573 Ok(Some(todo))
574 }
575
576 pub async fn new_todo(&self, draft: TodoDraft) -> Result<impl Todo + 'static, Box<dyn Error>> {
581 let uid = self.generate_uid(Kind::Todo).await?;
582 let todo = draft.resolve(&self.config, &self.now).into_ics(&uid);
583
584 let calendar_id = draft
586 .calendar_id
587 .as_deref()
588 .unwrap_or(&self.default_calendar);
589 let backend = self.get_store(calendar_id)?;
590
591 let resource_id = backend
593 .create_todo(&uid, &todo)
594 .await
595 .map_err(|e| format!("Failed to create todo in store: {e}"))?;
596
597 self.db.upsert_todo(&uid, &todo, calendar_id).await?;
599 self.db
600 .resources
601 .insert(&uid, calendar_id, &resource_id, None)
602 .await?;
603
604 let todo_with_id = self.short_ids.todo(todo).await?;
605 Ok(todo_with_id)
606 }
607
608 pub async fn update_todo(
613 &self,
614 id: &Id,
615 patch: TodoPatch,
616 ) -> Result<impl Todo + 'static, Box<dyn Error>> {
617 let uid = self.short_ids.get_uid(id).await?;
618 let Some(_todo) = self.db.todos.get(&uid).await? else {
619 return Err("Todo not found".into());
620 };
621
622 let todo_record = self.db.todos.get(&uid).await?.ok_or("Todo not found")?;
624 let backend = self.get_store(&todo_record.calendar_id)?;
625 let calendar_id = backend.calendar_id();
626
627 let updated_todo = backend
629 .update_todo(&uid, &patch)
630 .await
631 .map_err(|e| format!("Failed to update todo in store: {e}"))?;
632
633 self.db
635 .upsert_todo(&uid, &updated_todo, calendar_id)
636 .await?;
637
638 let todo = self.short_ids.todo(updated_todo).await?;
639 Ok(todo)
640 }
641
642 pub async fn get_todo(&self, id: &Id) -> Result<impl Todo + 'static, Box<dyn Error>> {
647 let uid = self.short_ids.get_uid(id).await?;
648 match self.db.todos.get(&uid).await {
649 Ok(Some(todo)) => Ok(self.short_ids.todo(todo).await?),
650 Ok(None) => Err("Event not found".into()),
651 Err(e) => Err(e.into()),
652 }
653 }
654
655 pub async fn list_todos(
660 &self,
661 conds: &TodoConditions,
662 sort: &[TodoSort],
663 pager: &Pager,
664 ) -> Result<Vec<impl Todo + 'static>, Box<dyn Error>> {
665 let conds = conds.resolve(&self.now)?;
666 let sort = TodoSort::resolve_vec(sort, &self.config);
667 let todos = self.db.todos.list(&conds, &sort, pager).await?;
668 let todos = self.short_ids.todos(todos).await?;
669 Ok(todos)
670 }
671
672 pub async fn count_todos(&self, conds: &TodoConditions) -> Result<i64, Box<dyn Error>> {
677 let conds = conds.resolve(&self.now)?;
678 Ok(self.db.todos.count(&conds).await?)
679 }
680
681 pub async fn list_calendars(&self) -> Result<Vec<CalendarRecord>, Box<dyn Error>> {
686 Ok(self.db.calendars.list().await?)
687 }
688
689 pub async fn get_calendar_details(&self, id: &str) -> Result<CalendarDetails, Box<dyn Error>> {
694 let record = self
695 .db
696 .calendars
697 .get(id)
698 .await?
699 .ok_or_else(|| format!("Calendar not found: {id}"))?;
700
701 let backend = self
702 .config
703 .calendars
704 .iter()
705 .find(|calendar| calendar.id == record.id)
706 .and_then(|calendar| {
707 self.config
708 .stores
709 .get(&calendar.store)
710 .map(|store_def| Self::calendar_store_details(calendar, store_def))
711 });
712
713 Ok(CalendarDetails {
714 id: record.id.clone(),
715 name: record.name.clone(),
716 kind: record.kind.clone(),
717 priority: record.priority,
718 enabled: record.enabled,
719 is_default: self.default_calendar == record.id,
720 created_at: record.created_at.clone(),
721 updated_at: record.updated_at.clone(),
722 store: backend,
723 })
724 }
725
726 #[must_use]
728 pub fn startup_notices(&self) -> &[String] {
729 &self.startup_notices
730 }
731
732 pub async fn flush_short_ids(&self) -> Result<(), Box<dyn Error>> {
737 self.short_ids.flush().await
738 }
739
740 pub async fn sync(&self) -> Result<SyncResult, Box<dyn Error>> {
745 let mut created = 0;
746 let mut updated = 0;
747 let mut deleted = 0;
748
749 for (calendar_id, backend) in &self.stores {
750 match backend.sync_cache().await {
751 Ok(result) => {
752 created += result.created;
753 updated += result.updated;
754 deleted += result.deleted;
755 }
756 Err(e) => {
757 return Err(format!("Failed to sync calendar '{calendar_id}': {e}").into());
758 }
759 }
760 }
761
762 Ok(SyncResult {
763 created,
764 updated,
765 deleted,
766 })
767 }
768
769 pub async fn close(self) -> Result<(), Box<dyn Error>> {
774 self.db.close().await
775 }
776
777 async fn generate_uid(&self, kind: Kind) -> Result<String, Box<dyn Error>> {
778 for i in 0..16 {
779 let uid = Uuid::new_v4().to_string(); tracing::debug!(
781 ?uid,
782 attempt = i + 1,
783 "generated uid, checking for uniqueness"
784 );
785
786 let exists = match kind {
787 Kind::Event => self.db.events.get(&uid).await?.is_some(),
788 Kind::Todo => self.db.todos.get(&uid).await?.is_some(),
789 };
790 if exists {
791 tracing::debug!(uid, ?kind, "uid already exists in db");
792 continue;
793 }
794
795 return Ok(uid);
796 }
797
798 tracing::warn!("failed to generate a unique uid after multiple attempts");
799 Err("Failed to generate a unique UID after multiple attempts".into())
800 }
801}
802
803async fn prepare(config: &Config) -> Result<(), Box<dyn Error>> {
804 if let Some(parent) = &config.state_dir {
805 tracing::debug!(path = %parent.display(), "ensuring state directory exists");
806 fs::create_dir_all(parent).await?;
807 }
808 Ok(())
809}
810
811async fn initialize_db(config: &Config) -> Result<Db, Box<dyn Error>> {
812 const NAME: &str = "aim.db";
813 let db = if let Some(parent) = &config.state_dir {
814 Db::open(Some(&parent.join(NAME))).await
815 } else {
816 Db::open(None).await
817 }
818 .map_err(|e| format!("Failed to initialize db: {e}"))?;
819
820 Ok(db)
821}