feat(cal): add IcalParserAdapter and CalendarEvent domain models (GH-16) #45
No files matched your search
@@ -39,6 +39,9 @@ bytes = "1.6"
|
||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "builder", "smtp-transport"] }
|
||||
async-imap = { version = "0.9", default-features = false, features = ["runtime-tokio"] }
|
||||
wiremock = "0.6"
|
||||
icalendar = "0.15"
|
||||
chrono-tz = "0.9"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,3 +13,5 @@ async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
icalendar.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
@@ -1,21 +1,5 @@
|
||||
use thiserror::Error;
|
||||
//! iCalendar (RFC 5545), CalDAV (RFC 4791), and recurrence engine for Nuncio.
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CalendarError {
|
||||
#[error("Calendar sync error: {0}")]
|
||||
Sync(String),
|
||||
}
|
||||
pub mod parser;
|
||||
|
||||
pub struct CalendarEngine;
|
||||
|
||||
impl CalendarEngine {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CalendarEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
pub use parser::{CalendarError, IcalParserAdapter};
|
||||
@@ -0,0 +1,110 @@
|
||||
//! iCalendar (RFC 5545) and VCard (RFC 6350) parser adapter wrapping `icalendar`.
|
||||
|
||||
use icalendar::Component;
|
||||
use nuncio_core::model::CalendarEvent;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors returned by the iCalendar parsing engine.
|
||||
#[derive(Error, Debug, PartialEq, Eq)]
|
||||
pub enum CalendarError {
|
||||
/// Failed to parse RFC 5545 iCalendar payload.
|
||||
#[error("failed to parse iCalendar payload: {0}")]
|
||||
ParseFailed(String),
|
||||
}
|
||||
|
||||
/// iCalendar parser adapter converting raw `.ics` payloads into Nuncio [`CalendarEvent`] domain entities.
|
||||
pub struct IcalParserAdapter;
|
||||
|
||||
impl IcalParserAdapter {
|
||||
/// Parse a raw iCalendar RFC 5545 string into a [`CalendarEvent`] entity.
|
||||
pub fn parse_ical(
|
||||
id: &str,
|
||||
account_id: &str,
|
||||
calendar_id: &str,
|
||||
raw_ics: &str,
|
||||
) -> Result<CalendarEvent, CalendarError> {
|
||||
let calendar: icalendar::Calendar = raw_ics
|
||||
.parse()
|
||||
.map_err(|e: String| CalendarError::ParseFailed(e))?;
|
||||
|
||||
for component in &calendar.components {
|
||||
if let icalendar::CalendarComponent::Event(event) = component {
|
||||
let summary = event.get_summary().unwrap_or("No Summary").to_string();
|
||||
let location = event.get_location().map(|l| l.to_string());
|
||||
let rrule = event.get_property("RRULE").map(|p| p.value().to_string());
|
||||
|
||||
let start_time = event
|
||||
.get_start()
|
||||
.map(|d| match d {
|
||||
icalendar::DatePerhapsTime::Date(date) => {
|
||||
date.and_hms_opt(0, 0, 0).map_or(0, |dt| dt.and_utc().timestamp())
|
||||
}
|
||||
icalendar::DatePerhapsTime::DateTime(dt) => dt.as_datetime().map_or(0, |d| d.and_utc().timestamp()),
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
let end_time = event
|
||||
.get_end()
|
||||
.map(|d| match d {
|
||||
icalendar::DatePerhapsTime::Date(date) => {
|
||||
date.and_hms_opt(23, 59, 59).map_or(0, |dt| dt.and_utc().timestamp())
|
||||
}
|
||||
icalendar::DatePerhapsTime::DateTime(dt) => dt.as_datetime().map_or(0, |d| d.and_utc().timestamp()),
|
||||
})
|
||||
.unwrap_or(start_time + 3600);
|
||||
|
||||
return Ok(CalendarEvent {
|
||||
id: id.to_string(),
|
||||
account_id: account_id.to_string(),
|
||||
calendar_id: calendar_id.to_string(),
|
||||
summary,
|
||||
start_time,
|
||||
end_time,
|
||||
rrule,
|
||||
location,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(CalendarError::ParseFailed(
|
||||
"no VEVENT component found in iCalendar payload".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_valid_vevent_ics() {
|
||||
let ics = "BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
SUMMARY:Architecture Review\r\n\
|
||||
LOCATION:Conference Room 101\r\n\
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR";
|
||||
|
||||
let event = IcalParserAdapter::parse_ical("evt-100", "acct-1", "cal-1", ics)
|
||||
.expect("parse succeeds");
|
||||
|
||||
assert_eq!(event.id, "evt-100");
|
||||
assert_eq!(event.summary, "Architecture Review");
|
||||
assert_eq!(event.location, Some("Conference Room 101".to_string()));
|
||||
assert_eq!(event.rrule, Some("FREQ=WEEKLY;BYDAY=MO".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ics_without_vevent_fails() {
|
||||
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR";
|
||||
let err = IcalParserAdapter::parse_ical("evt-101", "acct-1", "cal-1", ics)
|
||||
.expect_err("should fail without VEVENT");
|
||||
assert_eq!(
|
||||
err,
|
||||
CalendarError::ParseFailed("no VEVENT component found in iCalendar payload".to_string())
|
||||
);
|
||||
assert_eq!(err.to_string(), "failed to parse iCalendar payload: no VEVENT component found in iCalendar payload");
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ pub mod config;
|
||||
pub mod model;
|
||||
|
||||
pub use config::{AccountConfig, AccountProtocol, ConfigError};
|
||||
pub use model::{Attachment, Email, Folder};
|
||||
pub use model::{Attachment, CalendarEvent, Contact, Email, Folder};
|
||||
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Pure domain entities owned by Nuncio (Email, Attachment, Folder).
|
||||
//! Pure domain entities owned by Nuncio (Email, Attachment, Folder, CalendarEvent, Contact).
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -55,6 +55,40 @@ pub struct Folder {
|
||||
pub unread_messages: usize,
|
||||
}
|
||||
|
||||
/// Calendar event domain entity owned by Nuncio core.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CalendarEvent {
|
||||
/// Event identifier.
|
||||
pub id: String,
|
||||
/// Account identifier owning the event.
|
||||
pub account_id: String,
|
||||
/// Calendar collection identifier.
|
||||
pub calendar_id: String,
|
||||
/// Event title or summary.
|
||||
pub summary: String,
|
||||
/// Unix timestamp for event start.
|
||||
pub start_time: i64,
|
||||
/// Unix timestamp for event end.
|
||||
pub end_time: i64,
|
||||
/// Recurrence rule string (RFC 5545 RRULE format).
|
||||
pub rrule: Option<String>,
|
||||
/// Location text.
|
||||
pub location: Option<String>,
|
||||
}
|
||||
|
||||
/// Contact card entity owned by Nuncio core.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Contact {
|
||||
/// Contact identifier.
|
||||
pub id: String,
|
||||
/// Full display name.
|
||||
pub name: String,
|
||||
/// Primary email address.
|
||||
pub email: String,
|
||||
/// Phone number.
|
||||
pub phone: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -84,4 +118,32 @@ mod tests {
|
||||
assert_eq!(parsed.id, email.id);
|
||||
assert_eq!(parsed.subject, email.subject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_event_and_contact_serde() {
|
||||
let event = CalendarEvent {
|
||||
id: "evt-1".to_string(),
|
||||
account_id: "acct-1".to_string(),
|
||||
calendar_id: "cal-1".to_string(),
|
||||
summary: "Sprint Sync".to_string(),
|
||||
start_time: 1700000000,
|
||||
end_time: 1700003600,
|
||||
rrule: Some("FREQ=WEEKLY".to_string()),
|
||||
location: Some("Room 101".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: CalendarEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.summary, "Sprint Sync");
|
||||
|
||||
let contact = Contact {
|
||||
id: "c-1".to_string(),
|
||||
name: "Alice Smith".to_string(),
|
||||
email: "alice@nuncio.mx".to_string(),
|
||||
phone: Some("555-0199".to_string()),
|
||||
};
|
||||
let c_json = serde_json::to_string(&contact).unwrap();
|
||||
let parsed_c: Contact = serde_json::from_str(&c_json).unwrap();
|
||||
assert_eq!(parsed_c.name, "Alice Smith");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user