From b00272aceb365b6a0ce92b91e571a67c0c7d667d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 14:27:20 +0200 Subject: [PATCH] iCalendar export: domain service + API + MCP tool (#15) --- crates/domain/src/lib.rs | 2 +- crates/domain/src/services/ical.rs | 109 ++++++++++++++ crates/domain/src/services/mod.rs | 2 + crates/domain/src/services/tests/ical.rs | 145 +++++++++++++++++++ crates/mcp/src/server.rs | 12 +- crates/mcp/src/tools/ical.rs | 20 +++ crates/mcp/src/tools/mod.rs | 1 + crates/presentation/src/handlers/schedule.rs | 26 +++- crates/presentation/src/routes.rs | 1 + 9 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 crates/domain/src/services/ical.rs create mode 100644 crates/domain/src/services/tests/ical.rs create mode 100644 crates/mcp/src/tools/ical.rs diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index ee0cb2f..9ff90a6 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_m3u, generate_xmltv, ScheduleEngineService}; +pub use services::{generate_ical, generate_m3u, generate_xmltv, ScheduleEngineService}; pub use value_objects::*; diff --git a/crates/domain/src/services/ical.rs b/crates/domain/src/services/ical.rs new file mode 100644 index 0000000..0a2f7cc --- /dev/null +++ b/crates/domain/src/services/ical.rs @@ -0,0 +1,109 @@ +use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig}; +use crate::value_objects::Weekday; + +pub fn generate_ical(channel_name: &str, timezone: &str, config: &ScheduleConfig) -> String { + let mut out = String::new(); + out.push_str("BEGIN:VCALENDAR\r\n"); + out.push_str("VERSION:2.0\r\n"); + out.push_str("PRODID:-//K-TV//Schedule Export//EN\r\n"); + out.push_str("CALSCALE:GREGORIAN\r\n"); + out.push_str(&format!("X-WR-CALNAME:{}\r\n", fold_line(channel_name))); + out.push_str(&format!("X-WR-TIMEZONE:{}\r\n", timezone)); + + for day in Weekday::all() { + for block in config.blocks_for(day) { + write_vevent(&mut out, day, block); + } + } + + out.push_str("END:VCALENDAR\r\n"); + out +} + +fn write_vevent(out: &mut String, day: Weekday, block: &ProgrammingBlock) { + let byday = weekday_to_byday(day); + let hours = block.start_time().format("%H%M%S"); + let dur = format_duration(block.duration_mins()); + + out.push_str("BEGIN:VEVENT\r\n"); + out.push_str(&format!("UID:{}\r\n", block.id())); + out.push_str(&format!("DTSTART:{}\r\n", hours)); + out.push_str(&format!("DURATION:{}\r\n", dur)); + out.push_str(&format!("RRULE:FREQ=WEEKLY;BYDAY={}\r\n", byday)); + out.push_str(&format!("SUMMARY:{}\r\n", fold_line(block.name()))); + + let content_json = serde_json::to_string(block.content()).unwrap_or_default(); + write_folded_property(out, "X-KTV-CONTENT", &content_json); + + if let BlockContent::Algorithmic { strategy, .. } = block.content() { + let strategy_name = serde_json::to_string(strategy) + .unwrap_or_default() + .trim_matches('"') + .to_string(); + out.push_str(&format!("X-KTV-STRATEGY:{}\r\n", strategy_name)); + } + + if let Some(rule) = block.interstitial_rule() { + let json = serde_json::to_string(rule).unwrap_or_default(); + write_folded_property(out, "X-KTV-INTERSTITIAL", &json); + } + + if let Some(rule) = block.mid_roll_rule() { + let json = serde_json::to_string(rule).unwrap_or_default(); + write_folded_property(out, "X-KTV-MIDROLL", &json); + } + + out.push_str("END:VEVENT\r\n"); +} + +fn weekday_to_byday(day: Weekday) -> &'static str { + match day { + Weekday::Monday => "MO", + Weekday::Tuesday => "TU", + Weekday::Wednesday => "WE", + Weekday::Thursday => "TH", + Weekday::Friday => "FR", + Weekday::Saturday => "SA", + Weekday::Sunday => "SU", + } +} + +fn format_duration(mins: u32) -> String { + let h = mins / 60; + let m = mins % 60; + if h > 0 && m > 0 { + format!("PT{}H{}M", h, m) + } else if h > 0 { + format!("PT{}H", h) + } else { + format!("PT{}M", m) + } +} + +fn fold_line(value: &str) -> String { + value.replace('\\', "\\\\").replace(',', "\\,").replace(';', "\\;") +} + +fn write_folded_property(out: &mut String, name: &str, value: &str) { + let line = format!("{}:{}", name, value); + if line.len() <= 75 { + out.push_str(&line); + out.push_str("\r\n"); + return; + } + let bytes = line.as_bytes(); + out.push_str(&line[..75]); + out.push_str("\r\n"); + let mut pos = 75; + while pos < bytes.len() { + let end = (pos + 74).min(bytes.len()); + out.push(' '); + out.push_str(&line[pos..end]); + out.push_str("\r\n"); + pos = end; + } +} + +#[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 9df4b8e..9b612d3 100644 --- a/crates/domain/src/services/mod.rs +++ b/crates/domain/src/services/mod.rs @@ -1,5 +1,7 @@ +pub mod ical; pub mod iptv; pub mod schedule; +pub use ical::generate_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 new file mode 100644 index 0000000..2846f68 --- /dev/null +++ b/crates/domain/src/services/tests/ical.rs @@ -0,0 +1,145 @@ +use super::*; + +use crate::models::{ProgrammingBlock, ScheduleConfig}; +use crate::value_objects::{FillStrategy, MediaFilter, Weekday}; +use chrono::NaiveTime; +use std::collections::HashMap; + +fn make_config_with_blocks( + entries: Vec<(Weekday, Vec)>, +) -> ScheduleConfig { + let day_blocks: HashMap> = entries.into_iter().collect(); + ScheduleConfig::from_day_blocks(day_blocks) +} + +#[test] +fn vcalendar_header() { + let config = ScheduleConfig::new(); + let ical = generate_ical("Test Channel", "Europe/Warsaw", &config); + assert!(ical.contains("BEGIN:VCALENDAR\r\n")); + assert!(ical.contains("VERSION:2.0\r\n")); + assert!(ical.contains("PRODID:-//K-TV//Schedule Export//EN\r\n")); + assert!(ical.contains("CALSCALE:GREGORIAN\r\n")); + assert!(ical.contains("X-WR-CALNAME:Test Channel\r\n")); + assert!(ical.contains("X-WR-TIMEZONE:Europe/Warsaw\r\n")); + assert!(ical.contains("END:VCALENDAR\r\n")); +} + +#[test] +fn empty_config_produces_no_events() { + let config = ScheduleConfig::new(); + let ical = generate_ical("Empty", "UTC", &config); + assert!(!ical.contains("BEGIN:VEVENT")); +} + +#[test] +fn algorithmic_block_produces_vevent() { + let block = ProgrammingBlock::new_algorithmic( + "Morning Cartoons", + NaiveTime::from_hms_opt(8, 0, 0).unwrap(), + 120, + MediaFilter::default(), + FillStrategy::Random, + ); + let config = make_config_with_blocks(vec![(Weekday::Monday, vec![block.clone()])]); + let ical = generate_ical("Kids TV", "UTC", &config); + + assert!(ical.contains("BEGIN:VEVENT\r\n")); + assert!(ical.contains("END:VEVENT\r\n")); + assert!(ical.contains("DTSTART:080000\r\n")); + assert!(ical.contains("DURATION:PT2H\r\n")); + assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO\r\n")); + assert!(ical.contains("SUMMARY:Morning Cartoons\r\n")); + assert!(ical.contains(&format!("UID:{}\r\n", block.id()))); + assert!(ical.contains("X-KTV-STRATEGY:random\r\n")); + assert!(ical.contains("X-KTV-CONTENT:")); +} + +#[test] +fn different_days_produce_different_byday() { + let mon_block = ProgrammingBlock::new_algorithmic( + "Monday Block", + NaiveTime::from_hms_opt(10, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::Sequential, + ); + let fri_block = ProgrammingBlock::new_algorithmic( + "Friday Block", + NaiveTime::from_hms_opt(20, 0, 0).unwrap(), + 90, + MediaFilter::default(), + FillStrategy::BestFit, + ); + let config = make_config_with_blocks(vec![ + (Weekday::Monday, vec![mon_block]), + (Weekday::Friday, vec![fri_block]), + ]); + let ical = generate_ical("Multi-Day", "America/New_York", &config); + + assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO\r\n")); + assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=FR\r\n")); +} + +#[test] +fn manual_block_has_no_strategy() { + let block = ProgrammingBlock::new_manual( + "Manual Show", + NaiveTime::from_hms_opt(14, 30, 0).unwrap(), + 45, + vec![], + ); + let config = make_config_with_blocks(vec![(Weekday::Wednesday, vec![block])]); + let ical = generate_ical("Manual Ch", "UTC", &config); + + assert!(ical.contains("SUMMARY:Manual Show\r\n")); + assert!(ical.contains("DTSTART:143000\r\n")); + assert!(ical.contains("DURATION:PT45M\r\n")); + assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=WE\r\n")); + assert!(!ical.contains("X-KTV-STRATEGY:")); +} + +#[test] +fn duration_with_hours_and_minutes() { + let block = ProgrammingBlock::new_manual( + "Mixed Duration", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 150, + vec![], + ); + let config = make_config_with_blocks(vec![(Weekday::Sunday, vec![block])]); + let ical = generate_ical("Test", "UTC", &config); + + assert!(ical.contains("DURATION:PT2H30M\r\n")); + assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=SU\r\n")); +} + +#[test] +fn all_weekdays_mapped() { + let days_and_byday = [ + (Weekday::Monday, "MO"), + (Weekday::Tuesday, "TU"), + (Weekday::Wednesday, "WE"), + (Weekday::Thursday, "TH"), + (Weekday::Friday, "FR"), + (Weekday::Saturday, "SA"), + (Weekday::Sunday, "SU"), + ]; + + for (day, byday) in days_and_byday { + let block = ProgrammingBlock::new_manual( + "Test", + NaiveTime::from_hms_opt(12, 0, 0).unwrap(), + 60, + vec![], + ); + let config = make_config_with_blocks(vec![(day, vec![block])]); + let ical = generate_ical("Test", "UTC", &config); + assert!( + ical.contains(&format!("BYDAY={}\r\n", byday)), + "Expected BYDAY={} for {:?}", + byday, + day + ); + } +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index fe1af8b..5e518c7 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"; @@ -201,6 +201,16 @@ impl KTvMcpServer { ) .await } + + #[tool( + description = "Export a channel's schedule as iCalendar (.ics). Returns RFC 5545 text." + )] + 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(tool_box)] diff --git a/crates/mcp/src/tools/ical.rs b/crates/mcp/src/tools/ical.rs new file mode 100644 index 0000000..4241dc6 --- /dev/null +++ b/crates/mcp/src/tools/ical.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::error::domain_err; + +pub async fn export_schedule_ical( + channel_query: &Arc, + channel_id: Uuid, +) -> String { + match channel_query.find_by_id(channel_id.into()).await { + Ok(Some(channel)) => domain::generate_ical( + channel.name(), + channel.timezone(), + channel.schedule_config(), + ), + Ok(None) => serde_json::json!({"error": "Channel not found"}).to_string(), + Err(e) => domain_err(e), + } +} diff --git a/crates/mcp/src/tools/mod.rs b/crates/mcp/src/tools/mod.rs index e2181ea..c7acc98 100644 --- a/crates/mcp/src/tools/mod.rs +++ b/crates/mcp/src/tools/mod.rs @@ -1,3 +1,4 @@ pub mod channels; +pub mod ical; pub mod library; pub mod schedule; diff --git a/crates/presentation/src/handlers/schedule.rs b/crates/presentation/src/handlers/schedule.rs index 5b663af..0b787a8 100644 --- a/crates/presentation/src/handlers/schedule.rs +++ b/crates/presentation/src/handlers/schedule.rs @@ -1,6 +1,6 @@ use axum::Json; use axum::extract::{Path, State}; -use axum::http::StatusCode; +use axum::http::{StatusCode, header}; use axum::response::IntoResponse; use chrono::Utc; @@ -10,6 +10,7 @@ use api_types::{ use application::schedule::{ GenerateScheduleCommand, GetCurrentBroadcastQuery, GetEpgQuery, GetSourceQuery, }; +use domain::DomainError; use domain::value_objects::ChannelId; use crate::errors::AppError; @@ -91,3 +92,26 @@ pub async fn list_schedule_history( .collect(), )) } + +pub async fn export_ical( + State(state): State, + Path(id): Path, +) -> Result { + let channel = state + .channel_query + .find_by_id(id.into()) + .await? + .ok_or_else(|| AppError(DomainError::NotFound(format!("Channel {id} not found"))))?; + + let ical = domain::generate_ical(channel.name(), channel.timezone(), channel.schedule_config()); + let disposition = format!("attachment; filename=\"{}.ics\"", channel.name()); + + Ok(( + [ + (header::CONTENT_TYPE, "text/calendar; charset=utf-8".to_string()), + (header::CONTENT_DISPOSITION, disposition), + ], + ical, + ) + .into_response()) +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 1d49e58..86c74f5 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -44,6 +44,7 @@ fn channel_router() -> Router { .route("/{id}/now", get(handlers::schedule::get_current_broadcast)) .route("/{id}/epg", get(handlers::schedule::get_epg)) .route("/{id}/stream", get(handlers::schedule::get_stream)) + .route("/{id}/export.ics", get(handlers::schedule::export_ical)) .route("/{id}/snapshots", post(handlers::channels::save_snapshot)) .route("/{id}/snapshots", get(handlers::channels::list_snapshots)) .route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot))