use std::error::Error;
use std::path::{Path, PathBuf};
use aimcal_ical::{CalendarComponent, ICalendar, fmt::format, parse};
use tokio::fs;
use crate::db::Db;
#[tracing::instrument(skip(db))]
pub async fn add_calendar_if_enabled(
db: &Db,
calendar_path: Option<&PathBuf>,
) -> Result<(), Box<dyn Error>> {
let Some(path) = calendar_path else {
tracing::info!("calendar_path not configured, skipping ICS import");
return Ok(());
};
if !path.exists() {
tracing::warn!(
path = %path.display(),
"calendar_path does not exist, skipping ICS import"
);
return Ok(());
}
add_calendar(db, path).await
}
#[tracing::instrument(skip(db))]
async fn add_calendar(db: &Db, calendar_path: &PathBuf) -> Result<(), Box<dyn Error>> {
let mut reader = fs::read_dir(calendar_path)
.await
.map_err(|e| format!("Failed to read directory: {e}"))?;
let mut handles = vec![];
while let Some(entry) = reader.next_entry().await? {
let path = entry.path();
match path.extension() {
Some(ext) if ext == "ics" => {
let db = db.clone();
handles.push(tokio::spawn(async move {
if let Err(err) = add_ics(db, &path).await {
tracing::error!(path = %path.display(), err, "failed to process file");
}
}));
}
_ => {}
}
}
let count = handles.len();
for handle in handles {
handle.await?;
}
tracing::debug!(count = count, "total .ics files processed");
Ok(())
}
pub async fn parse_ics(path: &Path) -> Result<ICalendar<String>, Box<dyn Error>> {
let content = fs::read_to_string(path)
.await
.map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?;
let calendars = parse(&content)
.map_err(|e| -> Box<dyn Error> { format!("Failed to parse calendar: {e:?}").into() })?;
if calendars.is_empty() {
return Err("No calendars found in file".into());
}
Ok(calendars.into_iter().next().unwrap().to_owned())
}
pub async fn write_ics(path: &Path, calendar: &ICalendar<String>) -> Result<(), String> {
let ics_content = format(calendar).map_err(|e| format!("Failed to format calendar: {e}"))?;
fs::write(path, ics_content)
.await
.map_err(|e| format!("Failed to write calendar file: {e}"))
}
async fn add_ics(db: Db, path: &Path) -> Result<(), Box<dyn Error>> {
tracing::debug!(path = %path.display(), "parsing file");
let calendar = parse_ics(path).await?;
tracing::debug!(path = %path.display(), components = calendar.components.len(), "found components");
for component in calendar.components {
tracing::debug!(?component, "processing component");
match component {
CalendarComponent::Event(event) => {
let uid = event.uid.content.to_string();
db.upsert_event(&uid, &event, 0).await?;
let resource_id = format!("file://{}", path.display());
db.resources.insert(&uid, 0, &resource_id, None).await?;
}
CalendarComponent::Todo(todo) => {
let uid = todo.uid.content.to_string();
db.upsert_todo(&uid, &todo, 0).await?;
let resource_id = format!("file://{}", path.display());
db.resources.insert(&uid, 0, &resource_id, None).await?;
}
_ => tracing::warn!(?component, "ignoring unsupported component type"),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn add_calendar_processes_single_ics_file() {
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let calendar_path = temp_dir.path().to_path_buf();
let ics_content = "BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//AIM//Test//EN\r
BEGIN:VEVENT\r
UID:test-event-single\r
DTSTAMP:20250115T120000Z\r
DTSTART:20250115T100000Z\r
DTEND:20250115T110000Z\r
SUMMARY:Single Event\r
END:VEVENT\r
END:VCALENDAR\r
";
let event_file = calendar_path.join("event.ics");
fs::write(&event_file, ics_content)
.await
.expect("Failed to write test file");
let db = Db::open(None)
.await
.expect("Failed to create test database");
add_calendar(&db, &calendar_path)
.await
.expect("Failed to add calendar");
let event = db
.events
.get("test-event-single")
.await
.expect("Failed to get event");
assert!(event.is_some());
}
#[tokio::test]
async fn add_calendar_processes_multiple_ics_files() {
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let calendar_path = temp_dir.path().to_path_buf();
let event_ics = "BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//AIM//Test//EN\r
BEGIN:VEVENT\r
UID:test-event-1\r
DTSTAMP:20250115T120000Z\r
DTSTART:20250115T100000Z\r
DTEND:20250115T110000Z\r
SUMMARY:Event 1\r
END:VEVENT\r
END:VCALENDAR\r
";
fs::write(calendar_path.join("event1.ics"), event_ics)
.await
.expect("Failed to write event1.ics");
let todo_ics = "BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//AIM//Test//EN\r
BEGIN:VTODO\r
UID:test-todo-1\r
DTSTAMP:20250115T120000Z\r
SUMMARY:Todo 1\r
END:VTODO\r
END:VCALENDAR\r
";
fs::write(calendar_path.join("todo1.ics"), todo_ics)
.await
.expect("Failed to write todo1.ics");
let db = Db::open(None)
.await
.expect("Failed to create test database");
add_calendar(&db, &calendar_path)
.await
.expect("Failed to add calendar");
let event = db
.events
.get("test-event-1")
.await
.expect("Failed to get event");
let todo = db
.todos
.get("test-todo-1")
.await
.expect("Failed to get todo");
assert!(event.is_some());
assert!(todo.is_some());
}
#[tokio::test]
async fn add_calendar_skips_non_ics_files() {
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let calendar_path = temp_dir.path().to_path_buf();
fs::write(calendar_path.join("readme.txt"), "This is not an .ics file")
.await
.expect("Failed to write readme.txt");
fs::write(calendar_path.join("notes.md"), "# Notes")
.await
.expect("Failed to write notes.md");
let ics_content = "BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//AIM//Test//EN\r
BEGIN:VEVENT\r
UID:test-event-skip\r
DTSTAMP:20250115T120000Z\r
DTSTART:20250115T100000Z\r
DTEND:20250115T110000Z\r
SUMMARY:Skip Test Event\r
END:VEVENT\r
END:VCALENDAR\r
";
fs::write(calendar_path.join("event.ics"), ics_content)
.await
.expect("Failed to write event.ics");
let db = Db::open(None)
.await
.expect("Failed to create test database");
add_calendar(&db, &calendar_path)
.await
.expect("Failed to add calendar");
let event = db
.events
.get("test-event-skip")
.await
.expect("Failed to get event");
assert!(event.is_some());
}
#[tokio::test]
async fn add_calendar_handles_empty_directory() {
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let calendar_path = temp_dir.path().to_path_buf();
let db = Db::open(None)
.await
.expect("Failed to create test database");
let result = add_calendar(&db, &calendar_path).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn add_calendar_processes_files_with_multiple_components() {
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let calendar_path = temp_dir.path().to_path_buf();
let ics_content = "BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//AIM//Test//EN\r
BEGIN:VEVENT\r
UID:multi-event-1\r
DTSTAMP:20250115T120000Z\r
DTSTART:20250115T100000Z\r
DTEND:20250115T110000Z\r
SUMMARY:Multi Event 1\r
END:VEVENT\r
BEGIN:VTODO\r
UID:multi-todo-1\r
DTSTAMP:20250115T120000Z\r
SUMMARY:Multi Todo 1\r
END:VTODO\r
BEGIN:VEVENT\r
UID:multi-event-2\r
DTSTAMP:20250115T120000Z\r
DTSTART:20250115T120000Z\r
DTEND:20250115T130000Z\r
SUMMARY:Multi Event 2\r
END:VEVENT\r
END:VCALENDAR\r
";
fs::write(calendar_path.join("mixed.ics"), ics_content)
.await
.expect("Failed to write mixed.ics");
let db = Db::open(None)
.await
.expect("Failed to create test database");
add_calendar(&db, &calendar_path)
.await
.expect("Failed to add calendar");
let event1 = db
.events
.get("multi-event-1")
.await
.expect("Failed to get event 1");
let _event2 = db
.events
.get("multi-event-2")
.await
.expect("Failed to get event 2");
let todo = db
.todos
.get("multi-todo-1")
.await
.expect("Failed to get todo");
assert!(event1.is_some());
assert!(todo.is_some());
}
#[tokio::test]
async fn add_continues_on_corrupted_file() {
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let calendar_path = temp_dir.path().to_path_buf();
fs::write(
calendar_path.join("corrupted.ics"),
"INVALID CALENDAR CONTENT",
)
.await
.expect("Failed to write corrupted.ics");
let valid_ics = "BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//AIM//Test//EN\r
BEGIN:VEVENT\r
UID:valid-after-corrupt\r
DTSTAMP:20250115T120000Z\r
DTSTART:20250115T100000Z\r
DTEND:20250115T110000Z\r
SUMMARY:Valid Event\r
END:VEVENT\r
END:VCALENDAR\r
";
fs::write(calendar_path.join("valid.ics"), valid_ics)
.await
.expect("Failed to write valid.ics");
let db = Db::open(None)
.await
.expect("Failed to create test database");
let result = add_calendar(&db, &calendar_path).await;
assert!(result.is_ok());
let event = db
.events
.get("valid-after-corrupt")
.await
.expect("Failed to get event");
assert!(event.is_some());
}
}