From a7fa2ec4aafbfc13355ef70477a75782b3cd7a70 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 14:46:15 +0200 Subject: [PATCH] iCalendar import: parse_ical + API + MCP tool (#16) --- crates/domain/src/lib.rs | 2 +- crates/domain/src/models/channel.rs | 22 ++ crates/domain/src/services/ical.rs | 234 ++++++++++++++++++- crates/domain/src/services/mod.rs | 2 +- crates/domain/src/services/tests/ical.rs | 195 +++++++++++++++- crates/mcp/src/server.rs | 31 ++- crates/mcp/src/tools/ical.rs | 30 ++- crates/presentation/src/handlers/schedule.rs | 54 +++++ crates/presentation/src/routes.rs | 2 + 9 files changed, 565 insertions(+), 7 deletions(-) diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 9ff90a6..5daf0c8 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -10,5 +10,5 @@ pub mod value_objects; pub use errors::{DomainError, DomainResult}; pub use events::DomainEvent; pub use models::*; -pub use services::{generate_ical, generate_m3u, generate_xmltv, ScheduleEngineService}; +pub use services::{generate_ical, generate_m3u, generate_xmltv, parse_ical, ScheduleEngineService}; pub use value_objects::*; diff --git a/crates/domain/src/models/channel.rs b/crates/domain/src/models/channel.rs index 40923ab..f1f97f3 100644 --- a/crates/domain/src/models/channel.rs +++ b/crates/domain/src/models/channel.rs @@ -377,6 +377,28 @@ impl ProgrammingBlock { } } + pub fn from_parts( + id: BlockId, + name: impl Into, + start_time: NaiveTime, + duration_mins: u32, + content: BlockContent, + interstitial_rule: Option, + mid_roll_rule: Option, + ) -> Self { + Self { + id, + name: name.into(), + start_time, + duration_mins, + content, + loop_on_finish: true, + ignore_rotation_policy: false, + interstitial_rule, + mid_roll_rule, + } + } + pub fn new_manual( name: impl Into, start_time: NaiveTime, diff --git a/crates/domain/src/services/ical.rs b/crates/domain/src/services/ical.rs index 0a2f7cc..60ff60a 100644 --- a/crates/domain/src/services/ical.rs +++ b/crates/domain/src/services/ical.rs @@ -1,5 +1,10 @@ +use std::collections::HashMap; + +use chrono::NaiveTime; + +use crate::errors::DomainResult; use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig}; -use crate::value_objects::Weekday; +use crate::value_objects::{BlockId, FillStrategy, InterstitialRule, MediaFilter, MidRollRule, Weekday}; pub fn generate_ical(channel_name: &str, timezone: &str, config: &ScheduleConfig) -> String { let mut out = String::new(); @@ -104,6 +109,233 @@ fn write_folded_property(out: &mut String, name: &str, value: &str) { } } +pub fn parse_ical(ical_str: &str) -> DomainResult { + let unfolded = unfold_lines(ical_str); + let lines: Vec<&str> = unfolded.lines().collect(); + + if !lines.iter().any(|l| l.starts_with("BEGIN:VCALENDAR")) { + return Err(crate::DomainError::validation("missing BEGIN:VCALENDAR")); + } + if !lines.iter().any(|l| l.starts_with("END:VCALENDAR")) { + return Err(crate::DomainError::validation("missing END:VCALENDAR")); + } + + let mut day_blocks: HashMap> = HashMap::new(); + let mut in_vevent = false; + let mut props: Vec<(&str, &str)> = Vec::new(); + + for line in &lines { + if *line == "BEGIN:VEVENT" { + in_vevent = true; + props.clear(); + continue; + } + if *line == "END:VEVENT" { + if in_vevent { + let block = parse_vevent(&props)?; + let days = extract_byday(&props)?; + for day in days { + day_blocks.entry(day).or_default().push(block.clone()); + } + } + in_vevent = false; + continue; + } + if in_vevent && let Some((name, value)) = line.split_once(':') { + props.push((name, value)); + } + } + + Ok(ScheduleConfig::from_day_blocks(day_blocks)) +} + +fn unfold_lines(s: &str) -> String { + let normalized = s.replace("\r\n", "\n").replace('\r', "\n"); + let mut result = String::with_capacity(normalized.len()); + for line in normalized.split('\n') { + if line.starts_with(' ') || line.starts_with('\t') { + result.push_str(&line[1..]); + } else { + if !result.is_empty() { + result.push('\n'); + } + result.push_str(line); + } + } + result +} + +fn extract_byday(props: &[(&str, &str)]) -> DomainResult> { + let rrule = props + .iter() + .find(|(n, _)| *n == "RRULE") + .map(|(_, v)| *v) + .unwrap_or(""); + + let byday_part = rrule + .split(';') + .find(|p| p.starts_with("BYDAY=")) + .and_then(|p| p.strip_prefix("BYDAY=")); + + match byday_part { + Some(days_str) => { + let mut days = Vec::new(); + for d in days_str.split(',') { + days.push(byday_to_weekday(d.trim())?); + } + Ok(days) + } + None => Err(crate::DomainError::validation( + "VEVENT missing RRULE with BYDAY", + )), + } +} + +fn byday_to_weekday(s: &str) -> DomainResult { + match s { + "MO" => Ok(Weekday::Monday), + "TU" => Ok(Weekday::Tuesday), + "WE" => Ok(Weekday::Wednesday), + "TH" => Ok(Weekday::Thursday), + "FR" => Ok(Weekday::Friday), + "SA" => Ok(Weekday::Saturday), + "SU" => Ok(Weekday::Sunday), + _ => Err(crate::DomainError::validation(format!( + "unknown BYDAY value: {s}" + ))), + } +} + +fn parse_vevent(props: &[(&str, &str)]) -> DomainResult { + let uid = prop_value(props, "UID").unwrap_or(""); + let id: BlockId = if uid.is_empty() { + BlockId::generate() + } else { + uid.parse() + .unwrap_or_else(|_| BlockId::generate()) + }; + + let name = prop_value(props, "SUMMARY") + .map(unfold_value) + .unwrap_or_else(|| "Untitled".to_string()); + + let start_time = parse_dtstart(prop_value(props, "DTSTART").unwrap_or(""))?; + let duration_mins = parse_duration(prop_value(props, "DURATION").unwrap_or(""))?; + + let content = match prop_value(props, "X-KTV-CONTENT") { + Some(json) => serde_json::from_str(json).map_err(|e| { + crate::DomainError::validation(format!("invalid X-KTV-CONTENT: {e}")) + })?, + None => default_content(props), + }; + + let interstitial_rule = match prop_value(props, "X-KTV-INTERSTITIAL") { + Some(json) => Some(serde_json::from_str::(json).map_err(|e| { + crate::DomainError::validation(format!("invalid X-KTV-INTERSTITIAL: {e}")) + })?), + None => None, + }; + + let mid_roll_rule = match prop_value(props, "X-KTV-MIDROLL") { + Some(json) => Some(serde_json::from_str::(json).map_err(|e| { + crate::DomainError::validation(format!("invalid X-KTV-MIDROLL: {e}")) + })?), + None => None, + }; + + Ok(ProgrammingBlock::from_parts( + id, + name, + start_time, + duration_mins, + content, + interstitial_rule, + mid_roll_rule, + )) +} + +fn default_content(props: &[(&str, &str)]) -> BlockContent { + let strategy = match prop_value(props, "X-KTV-STRATEGY") { + Some(s) => parse_strategy(s).unwrap_or(FillStrategy::Random), + None => FillStrategy::Random, + }; + BlockContent::Algorithmic { + filter: MediaFilter::default(), + strategy, + } +} + +fn parse_strategy(s: &str) -> DomainResult { + let quoted = format!("\"{}\"", s); + serde_json::from_str("ed) + .map_err(|e| crate::DomainError::validation(format!("invalid strategy '{s}': {e}"))) +} + +fn prop_value<'a>(props: &[(&str, &'a str)], name: &str) -> Option<&'a str> { + props.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) +} + +fn unfold_value(s: &str) -> String { + s.replace("\\\\", "\x00") + .replace("\\,", ",") + .replace("\\;", ";") + .replace('\x00', "\\") +} + +fn parse_dtstart(s: &str) -> DomainResult { + if s.len() < 6 { + return Err(crate::DomainError::validation(format!( + "invalid DTSTART: {s}" + ))); + } + let time_part = if s.contains('T') { + s.split('T').next_back().unwrap_or(s) + } else { + s + }; + let digits = &time_part[..6.min(time_part.len())]; + NaiveTime::parse_from_str(digits, "%H%M%S") + .map_err(|e| crate::DomainError::validation(format!("invalid DTSTART time '{s}': {e}"))) +} + +fn parse_duration(s: &str) -> DomainResult { + if !s.starts_with("PT") { + return Err(crate::DomainError::validation(format!( + "invalid DURATION: {s}" + ))); + } + let body = &s[2..]; + let mut total_mins: u32 = 0; + let mut num_buf = String::new(); + + for c in body.chars() { + if c.is_ascii_digit() { + num_buf.push(c); + } else { + let n: u32 = num_buf.parse().map_err(|_| { + crate::DomainError::validation(format!("invalid DURATION number in: {s}")) + })?; + num_buf.clear(); + match c { + 'H' => total_mins += n * 60, + 'M' => total_mins += n, + 'S' => total_mins += n / 60, + _ => { + return Err(crate::DomainError::validation(format!( + "unknown DURATION unit '{c}' in: {s}" + ))) + } + } + } + } + if total_mins == 0 { + return Err(crate::DomainError::validation(format!( + "zero DURATION: {s}" + ))); + } + Ok(total_mins) +} + #[cfg(test)] #[path = "tests/ical.rs"] mod tests; diff --git a/crates/domain/src/services/mod.rs b/crates/domain/src/services/mod.rs index 9b612d3..5280d29 100644 --- a/crates/domain/src/services/mod.rs +++ b/crates/domain/src/services/mod.rs @@ -2,6 +2,6 @@ pub mod ical; pub mod iptv; pub mod schedule; -pub use ical::generate_ical; +pub use ical::{generate_ical, parse_ical}; pub use iptv::{generate_m3u, generate_xmltv}; pub use schedule::ScheduleEngineService; diff --git a/crates/domain/src/services/tests/ical.rs b/crates/domain/src/services/tests/ical.rs index 2846f68..7b21583 100644 --- a/crates/domain/src/services/tests/ical.rs +++ b/crates/domain/src/services/tests/ical.rs @@ -1,7 +1,7 @@ use super::*; -use crate::models::{ProgrammingBlock, ScheduleConfig}; -use crate::value_objects::{FillStrategy, MediaFilter, Weekday}; +use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig}; +use crate::value_objects::{FillStrategy, InterstitialRule, MediaFilter, MidRollRule, Weekday}; use chrono::NaiveTime; use std::collections::HashMap; @@ -143,3 +143,194 @@ fn all_weekdays_mapped() { ); } } + +#[test] +fn roundtrip_algorithmic_block() { + let block = ProgrammingBlock::new_algorithmic( + "Morning Cartoons", + NaiveTime::from_hms_opt(8, 0, 0).unwrap(), + 120, + MediaFilter::default(), + FillStrategy::Random, + ); + let original = make_config_with_blocks(vec![(Weekday::Monday, vec![block.clone()])]); + let ical = generate_ical("Test", "UTC", &original); + let parsed = parse_ical(&ical).unwrap(); + + let blocks = parsed.blocks_for(Weekday::Monday); + assert_eq!(blocks.len(), 1); + let b = &blocks[0]; + assert_eq!(b.name(), "Morning Cartoons"); + assert_eq!(b.start_time(), NaiveTime::from_hms_opt(8, 0, 0).unwrap()); + assert_eq!(b.duration_mins(), 120); + assert_eq!(b.id(), block.id()); + assert!(matches!(b.content(), BlockContent::Algorithmic { strategy: FillStrategy::Random, .. })); +} + +#[test] +fn roundtrip_multi_day() { + let mon = ProgrammingBlock::new_algorithmic( + "Mon Block", + NaiveTime::from_hms_opt(10, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::Sequential, + ); + let fri = ProgrammingBlock::new_algorithmic( + "Fri Block", + NaiveTime::from_hms_opt(20, 0, 0).unwrap(), + 90, + MediaFilter::default(), + FillStrategy::BestFit, + ); + let original = make_config_with_blocks(vec![ + (Weekday::Monday, vec![mon]), + (Weekday::Friday, vec![fri]), + ]); + let ical = generate_ical("Multi", "UTC", &original); + let parsed = parse_ical(&ical).unwrap(); + + assert_eq!(parsed.blocks_for(Weekday::Monday).len(), 1); + assert_eq!(parsed.blocks_for(Weekday::Friday).len(), 1); + assert_eq!(parsed.blocks_for(Weekday::Monday)[0].name(), "Mon Block"); + assert_eq!(parsed.blocks_for(Weekday::Friday)[0].name(), "Fri Block"); +} + +#[test] +fn roundtrip_manual_block() { + let block = ProgrammingBlock::new_manual( + "Manual Show", + NaiveTime::from_hms_opt(14, 30, 0).unwrap(), + 45, + vec![], + ); + let original = make_config_with_blocks(vec![(Weekday::Wednesday, vec![block])]); + let ical = generate_ical("Manual", "UTC", &original); + let parsed = parse_ical(&ical).unwrap(); + + let blocks = parsed.blocks_for(Weekday::Wednesday); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].name(), "Manual Show"); + assert_eq!(blocks[0].duration_mins(), 45); + assert!(matches!(blocks[0].content(), BlockContent::Manual { .. })); +} + +#[test] +fn roundtrip_with_interstitial_and_midroll() { + let mut block = ProgrammingBlock::new_algorithmic( + "Full Block", + NaiveTime::from_hms_opt(20, 0, 0).unwrap(), + 180, + MediaFilter::default(), + FillStrategy::Random, + ); + let interstitial = InterstitialRule::new(MediaFilter::default(), FillStrategy::Random, 30); + let midroll = MidRollRule::new(true, 15, 60, MediaFilter::default()); + block = ProgrammingBlock::from_parts( + block.id(), + block.name().to_string(), + block.start_time(), + block.duration_mins(), + block.content().clone(), + Some(interstitial), + Some(midroll), + ); + + let original = make_config_with_blocks(vec![(Weekday::Saturday, vec![block])]); + let ical = generate_ical("Full", "UTC", &original); + let parsed = parse_ical(&ical).unwrap(); + + let blocks = parsed.blocks_for(Weekday::Saturday); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].interstitial_rule().is_some()); + assert!(blocks[0].mid_roll_rule().is_some()); + assert_eq!(blocks[0].interstitial_rule().unwrap().min_gap_secs(), 30); + assert!(blocks[0].mid_roll_rule().unwrap().prefer_chapters()); +} + +#[test] +fn import_without_ktv_properties_uses_defaults() { + let ical = "BEGIN:VCALENDAR\r\n\ + VERSION:2.0\r\n\ + BEGIN:VEVENT\r\n\ + SUMMARY:Generic Event\r\n\ + DTSTART:090000\r\n\ + DURATION:PT1H\r\n\ + RRULE:FREQ=WEEKLY;BYDAY=TU\r\n\ + END:VEVENT\r\n\ + END:VCALENDAR\r\n"; + + let parsed = parse_ical(ical).unwrap(); + let blocks = parsed.blocks_for(Weekday::Tuesday); + assert_eq!(blocks.len(), 1); + let b = &blocks[0]; + assert_eq!(b.name(), "Generic Event"); + assert_eq!(b.start_time(), NaiveTime::from_hms_opt(9, 0, 0).unwrap()); + assert_eq!(b.duration_mins(), 60); + assert!(matches!( + b.content(), + BlockContent::Algorithmic { strategy: FillStrategy::Random, .. } + )); + assert!(b.interstitial_rule().is_none()); + assert!(b.mid_roll_rule().is_none()); +} + +#[test] +fn import_invalid_ical_missing_vcalendar() { + let result = parse_ical("not an ical file"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("BEGIN:VCALENDAR"), "error: {err}"); +} + +#[test] +fn import_invalid_ical_bad_duration() { + let ical = "BEGIN:VCALENDAR\r\n\ + BEGIN:VEVENT\r\n\ + SUMMARY:Bad\r\n\ + DTSTART:090000\r\n\ + DURATION:INVALID\r\n\ + RRULE:FREQ=WEEKLY;BYDAY=MO\r\n\ + END:VEVENT\r\n\ + END:VCALENDAR\r\n"; + + let result = parse_ical(ical); + assert!(result.is_err()); +} + +#[test] +fn import_handles_line_folding() { + let long_summary = "A".repeat(100); + let block = ProgrammingBlock::new_algorithmic( + &long_summary, + NaiveTime::from_hms_opt(10, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::Random, + ); + let original = make_config_with_blocks(vec![(Weekday::Thursday, vec![block])]); + let ical = generate_ical("Fold Test", "UTC", &original); + let parsed = parse_ical(&ical).unwrap(); + + let blocks = parsed.blocks_for(Weekday::Thursday); + assert_eq!(blocks.len(), 1); +} + +#[test] +fn import_multiple_byday() { + let ical = "BEGIN:VCALENDAR\r\n\ + VERSION:2.0\r\n\ + BEGIN:VEVENT\r\n\ + SUMMARY:Weekday Show\r\n\ + DTSTART:180000\r\n\ + DURATION:PT2H\r\n\ + RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\n\ + END:VEVENT\r\n\ + END:VCALENDAR\r\n"; + + let parsed = parse_ical(ical).unwrap(); + assert_eq!(parsed.blocks_for(Weekday::Monday).len(), 1); + assert_eq!(parsed.blocks_for(Weekday::Wednesday).len(), 1); + assert_eq!(parsed.blocks_for(Weekday::Friday).len(), 1); + assert_eq!(parsed.blocks_for(Weekday::Tuesday).len(), 0); +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 6f5cf03..52d2709 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -14,7 +14,7 @@ use schemars::JsonSchema; use serde::Deserialize; use uuid::Uuid; -use crate::tools::{channels, library, schedule}; +use crate::tools::{channels, ical, library, schedule}; const SERVER_NAME: &str = "k-tv-mcp"; @@ -151,6 +151,12 @@ pub struct SetGapFillerParams { pub filter_json: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ImportIcalParams { + pub channel_id: String, + pub ical_string: String, +} + fn parse_uuid(s: &str) -> Result { s.parse::() .map_err(|_| serde_json::json!({"error": format!("invalid UUID: {s}")}).to_string()) @@ -423,6 +429,29 @@ impl KTvMcpServer { ) .await } + + #[tool( + description = "Export a channel's ScheduleConfig as an iCalendar (.ics) string per RFC 5545." + )] + async fn export_schedule_ical(&self, #[tool(aggr)] p: ChannelIdParam) -> String { + match parse_uuid(&p.channel_id) { + Ok(id) => ical::export_schedule_ical(&self.channel_query, id).await, + Err(e) => e, + } + } + + #[tool( + description = "Import an iCalendar (.ics) string to replace a channel's ScheduleConfig. VEVENTs map to ProgrammingBlocks." + )] + async fn import_schedule_ical(&self, #[tool(aggr)] p: ImportIcalParams) -> String { + match parse_uuid(&p.channel_id) { + Ok(id) => { + ical::import_schedule_ical(&self.channel_cmd_deps, id, self.owner_id, &p.ical_string) + .await + } + Err(e) => e, + } + } } #[tool(tool_box)] diff --git a/crates/mcp/src/tools/ical.rs b/crates/mcp/src/tools/ical.rs index 4241dc6..5504019 100644 --- a/crates/mcp/src/tools/ical.rs +++ b/crates/mcp/src/tools/ical.rs @@ -1,8 +1,9 @@ use std::sync::Arc; +use application::channels::{ChannelCommandDeps, UpdateChannelCommand}; use uuid::Uuid; -use crate::error::domain_err; +use crate::error::{domain_err, ok_json}; pub async fn export_schedule_ical( channel_query: &Arc, @@ -18,3 +19,30 @@ pub async fn export_schedule_ical( Err(e) => domain_err(e), } } + +pub async fn import_schedule_ical( + cmd_deps: &Arc, + channel_id: Uuid, + owner_id: Uuid, + ical_str: &str, +) -> String { + let config = match domain::parse_ical(ical_str) { + Ok(c) => c, + Err(e) => return domain_err(e), + }; + let cmd = UpdateChannelCommand { + channel_id: channel_id.into(), + owner_id: owner_id.into(), + name: None, + description: None, + timezone: None, + schedule_config: Some(config), + rotation_policy: None, + auto_schedule: None, + gap_filler: None, + }; + match application::channels::update::execute(cmd_deps, cmd).await { + Ok(channel) => ok_json(&channel), + Err(e) => domain_err(e), + } +} diff --git a/crates/presentation/src/handlers/schedule.rs b/crates/presentation/src/handlers/schedule.rs index 48f84b1..1ef738a 100644 --- a/crates/presentation/src/handlers/schedule.rs +++ b/crates/presentation/src/handlers/schedule.rs @@ -170,3 +170,57 @@ pub async fn list_schedule_history( .collect(), )) } + +pub async fn export_ical( + State(state): State, + CurrentUser(_user): CurrentUser, + Path(id): Path, +) -> impl IntoResponse { + let channel_id = ChannelId::from(id); + match state.channel_query.find_by_id(channel_id).await { + Ok(Some(channel)) => { + let ical = domain::generate_ical( + channel.name(), + channel.timezone(), + channel.schedule_config(), + ); + ( + StatusCode::OK, + [ + ("content-type", "text/calendar; charset=utf-8"), + ( + "content-disposition", + &format!("attachment; filename=\"{}.ics\"", channel.name()), + ), + ], + ical, + ) + .into_response() + } + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +pub async fn import_ical( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + body: String, +) -> Result, AppError> { + let config = domain::parse_ical(&body)?; + let cmd = application::channels::UpdateChannelCommand { + channel_id: id.into(), + owner_id: user.id(), + name: None, + description: None, + timezone: None, + schedule_config: Some(config), + rotation_policy: None, + auto_schedule: None, + gap_filler: None, + }; + let channel = + application::channels::update::execute(&state.channel_command_deps, cmd).await?; + Ok(Json(api_types::ChannelResponse::from(channel))) +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 7a0502d..f6eeb32 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -58,6 +58,8 @@ fn channel_router() -> Router { .route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot)) .route("/{id}/snapshots/{snapshot_id}", axum::routing::patch(handlers::channels::patch_snapshot)) .route("/{id}/snapshots/{snapshot_id}/restore", post(handlers::channels::restore_snapshot)) + .route("/{id}/export.ics", get(handlers::schedule::export_ical)) + .route("/{id}/import", post(handlers::schedule::import_ical)) } fn admin_router() -> Router {