iCalendar export: domain service + API + MCP tool (#15)
This commit is contained in:
@@ -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::*;
|
||||
|
||||
109
crates/domain/src/services/ical.rs
Normal file
109
crates/domain/src/services/ical.rs
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
145
crates/domain/src/services/tests/ical.rs
Normal file
145
crates/domain/src/services/tests/ical.rs
Normal file
@@ -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<ProgrammingBlock>)>,
|
||||
) -> ScheduleConfig {
|
||||
let day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user