diff --git a/Cargo.lock b/Cargo.lock index 56c5585..15b5ccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -536,6 +536,7 @@ dependencies = [ "expect-test", "gnomon-import", "jscalendar", + "rfc5545-types", "serde_json", ] diff --git a/crates/gnomon-export/Cargo.toml b/crates/gnomon-export/Cargo.toml index c514a5e..c970e3c 100644 --- a/crates/gnomon-export/Cargo.toml +++ b/crates/gnomon-export/Cargo.toml @@ -8,6 +8,7 @@ gnomon-import = { path = "../gnomon-import" } calico = "0.5" calendar-types = "0.1" jscalendar = { version = "0.1", features = ["serde_json"] } +rfc5545-types = "0.1" serde_json = "1" [dev-dependencies] diff --git a/crates/gnomon-export/src/jscal.rs b/crates/gnomon-export/src/jscal.rs index 5a5cb1f..2d64013 100644 --- a/crates/gnomon-export/src/jscal.rs +++ b/crates/gnomon-export/src/jscal.rs @@ -1,22 +1,35 @@ //! JSCalendar export: ImportValue → jscalendar model → JSON (RFC 9553). -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::num::NonZero; use std::str::FromStr; use gnomon_import::{ImportRecord, ImportValue}; -use jscalendar::json::{IntoJson, TryFromJson}; -use jscalendar::model::object::{Event, Group, Task, TaskOrEvent}; +use jscalendar::json::{IntoJson, TryFromJson, UnsignedInt}; +use jscalendar::model::object::{ + Event, Group, Link, Location, Participant, Relation, ReplyTo, Task, TaskOrEvent, + TaskParticipant, VirtualLocation, +}; +use jscalendar::model::rrule::weekday_num_set::WeekdayNumSet; +use jscalendar::model::rrule::{ + ByMonthDayRule, ByPeriodDayRules, CoreByRules, FreqByRules, HourSet, Interval, MinuteSet, + MonthDay, MonthDaySet, MonthDaySetIndex, MonthSet, RRule, SecondSet, Termination, WeekNoSet, + WeekNoSetIndex, WeekdayNum, YearDayNum, YearlyByRules, +}; use jscalendar::model::set::{ - Color, EventStatus, FreeBusyStatus, Percent, Priority, Privacy, TaskProgress, + Color, EventStatus, FreeBusyStatus, Method, ParticipantRole, Percent, Priority, Privacy, + RelationValue, TaskProgress, }; +use jscalendar::model::string::{CalAddress, EmailAddr, GeoUri, Id, Uri}; use jscalendar::model::time::{ Date, DateTime, Day, Duration, ExactDuration, Hour, Local, Minute, Month, NominalDuration, - Second, Time, Year, + Second, Sign, Time, TimeFormat, Utc, Weekday, Year, }; use serde_json::{Map, Value as Json}; use calendar_types::set::Token; use calendar_types::string::Uid; +use calendar_types::time::IsoWeek; // ── Public API ─────────────────────────────────────────────── @@ -55,7 +68,7 @@ fn build_group( let ImportValue::Record(record) = entry else { return None; }; - match build_entry(record) { + match build_entry(record, warnings) { Ok(entry) => Some(entry), Err(err) => { warnings.push(err); @@ -93,8 +106,9 @@ fn build_group( group.set_keywords(set); } - // Vendor properties. - let vendor = collect_vendor_properties(calendar, GROUP_KNOWN); + // r[impl model.export.jscalendar.vendor] + // r[impl model.export.jscalendar.unknown] + let vendor = collect_vendor_properties(calendar, GROUP_KNOWN, "calendar", warnings); for (k, v) in vendor { group.insert_vendor_property(k.into(), v); } @@ -118,20 +132,23 @@ const GROUP_KNOWN: &[&str] = &[ // ── Entry dispatch ─────────────────────────────────────────── -fn build_entry(record: &ImportRecord) -> Result, String> { +fn build_entry( + record: &ImportRecord, + warnings: &mut Vec, +) -> Result, String> { let entry_type = get_str(record, "type").unwrap_or("event"); match entry_type { - // r[impl model.export.jscalendar.event] - "event" => build_event(record).map(TaskOrEvent::Event), - // r[impl model.export.jscalendar.task] - "task" => build_task(record).map(TaskOrEvent::Task), + // r[impl model.export.jscalendar.event+2] + "event" => build_event(record, warnings).map(TaskOrEvent::Event), + // r[impl model.export.jscalendar.task+2] + "task" => build_task(record, warnings).map(TaskOrEvent::Task), other => Err(format!("unknown entry type: {other}")), } } // ── Event builder ──────────────────────────────────────────── -fn build_event(record: &ImportRecord) -> Result, String> { +fn build_event(record: &ImportRecord, warnings: &mut Vec) -> Result, String> { let uid = get_uid(record)?; let start = get_datetime(record, "start") .ok_or_else(|| "event missing required 'start' field".to_string())?; @@ -190,8 +207,71 @@ fn build_event(record: &ImportRecord) -> Result, String> { event.set_keywords(set); } + // ── Metadata properties ────────────────────────────────── + if let Some(dt) = get_utc_datetime(record, "created") { + event.set_created(dt); + } + // "last_modified" (iCal import) or "updated" (JSCal import) → JSCalendar `updated` + if let Some(dt) = + get_utc_datetime(record, "updated").or_else(|| get_utc_datetime(record, "last_modified")) + { + event.set_updated(dt); + } + if let Some(n) = get_u64(record, "sequence") + && let Some(ui) = UnsignedInt::new(n) + { + event.set_sequence(ui); + } + if let Some(s) = get_str(record, "method") { + let m = Token::>::from_str(s).unwrap(); + event.set_method(m); + } + + // ── Recurrence properties ──────────────────────────────── + if let Some(dt) = get_datetime(record, "recurrence_id") { + event.set_recurrence_id(dt); + } + if let Some(rules) = get_recurrence_rules(record) { + event.set_recurrence_rules(rules); + } + if let Some(rules) = get_excluded_recurrence_rules(record) { + event.set_excluded_recurrence_rules(rules); + } + + // ── Location properties ────────────────────────────────── + if let Some(locations) = build_locations(record) { + event.set_locations(locations); + } + if let Some(vlocs) = build_virtual_locations(record) { + event.set_virtual_locations(vlocs); + } + + // ── Link properties ────────────────────────────────────── + if let Some(links) = build_links(record) { + event.set_links(links); + } + + // ── Relation properties ────────────────────────────────── + if let Some(related) = build_related_to(record) { + event.set_related_to(related); + } + + // ── Participant properties ─────────────────────────────── + if let Some(participants) = build_event_participants(record) { + event.set_participants(participants); + } + + // ── Scheduling properties ──────────────────────────────── + if let Some(reply_to) = build_reply_to(record) { + event.set_reply_to(reply_to); + } + if let Some(rs) = build_request_status(record) { + event.set_request_status(rs); + } + // r[impl model.export.jscalendar.vendor] - let vendor = collect_vendor_properties(record, EVENT_KNOWN); + // r[impl model.export.jscalendar.unknown] + let vendor = collect_vendor_properties(record, EVENT_KNOWN, "event", warnings); for (k, v) in vendor { event.insert_vendor_property(k.into(), v); } @@ -217,11 +297,41 @@ const EVENT_KNOWN: &[&str] = &[ "show_without_time", "categories", "keywords", + // Metadata + "created", + "updated", + "last_modified", + "sequence", + "method", + // Recurrence + "recurrence_id", + "recur", + "recurrence_rules", + "excluded_recurrence_rules", + // Locations + "location", + "geo", + "locations", + "virtual_locations", + // Links + "url", + "attachments", + "links", + // Relations + "related_to", + // Participants + "organizer", + "attendees", + "participants", + // Scheduling + "reply_to", + "request_status", + "request_statuses", ]; // ── Task builder ───────────────────────────────────────────── -fn build_task(record: &ImportRecord) -> Result, String> { +fn build_task(record: &ImportRecord, warnings: &mut Vec) -> Result, String> { let uid = get_uid(record)?; let mut task = Task::new(uid); @@ -290,8 +400,70 @@ fn build_task(record: &ImportRecord) -> Result, String> { task.set_keywords(set); } + // ── Metadata properties ────────────────────────────────── + if let Some(dt) = get_utc_datetime(record, "created") { + task.set_created(dt); + } + if let Some(dt) = + get_utc_datetime(record, "updated").or_else(|| get_utc_datetime(record, "last_modified")) + { + task.set_updated(dt); + } + if let Some(n) = get_u64(record, "sequence") + && let Some(ui) = UnsignedInt::new(n) + { + task.set_sequence(ui); + } + if let Some(s) = get_str(record, "method") { + let m = Token::>::from_str(s).unwrap(); + task.set_method(m); + } + + // ── Recurrence properties ──────────────────────────────── + if let Some(dt) = get_datetime(record, "recurrence_id") { + task.set_recurrence_id(dt); + } + if let Some(rules) = get_recurrence_rules(record) { + task.set_recurrence_rules(rules); + } + if let Some(rules) = get_excluded_recurrence_rules(record) { + task.set_excluded_recurrence_rules(rules); + } + + // ── Location properties ────────────────────────────────── + if let Some(locations) = build_locations(record) { + task.set_locations(locations); + } + if let Some(vlocs) = build_virtual_locations(record) { + task.set_virtual_locations(vlocs); + } + + // ── Link properties ────────────────────────────────────── + if let Some(links) = build_links(record) { + task.set_links(links); + } + + // ── Relation properties ────────────────────────────────── + if let Some(related) = build_related_to(record) { + task.set_related_to(related); + } + + // ── Participant properties ─────────────────────────────── + if let Some(participants) = build_task_participants(record) { + task.set_participants(participants); + } + + // ── Scheduling properties ──────────────────────────────── + if let Some(reply_to) = build_reply_to(record) { + task.set_reply_to(reply_to); + } + if let Some(rs) = build_request_status(record) { + task.set_request_status(rs); + } + // r[impl model.export.jscalendar.vendor] - let vendor = collect_vendor_properties(record, TASK_KNOWN); + // r[impl model.export.jscalendar.unknown] + let vendor = collect_vendor_properties(record, TASK_KNOWN, "task", warnings); for (k, v) in vendor { task.insert_vendor_property(k.into(), v); } @@ -319,6 +491,36 @@ const TASK_KNOWN: &[&str] = &[ "show_without_time", "categories", "keywords", + // Metadata + "created", + "updated", + "last_modified", + "sequence", + "method", + // Recurrence + "recurrence_id", + "recur", + "recurrence_rules", + "excluded_recurrence_rules", + // Locations + "location", + "geo", + "locations", + "virtual_locations", + // Links + "url", + "attachments", + "links", + // Relations + "related_to", + // Participants + "organizer", + "attendees", + "participants", + // Scheduling + "reply_to", + "request_status", + "request_statuses", ]; // ── Value extraction helpers ───────────────────────────────── @@ -337,6 +539,14 @@ fn get_u64(record: &ImportRecord, key: &str) -> Option { } } +fn get_i64(record: &ImportRecord, key: &str) -> Option { + match record.get(key)? { + ImportValue::Integer(n) => i64::try_from(*n).ok(), + ImportValue::SignedInteger(n) => Some(*n), + _ => None, + } +} + fn get_bool(record: &ImportRecord, key: &str) -> Option { match record.get(key)? { ImportValue::Bool(b) => Some(*b), @@ -390,6 +600,49 @@ fn get_datetime(record: &ImportRecord, key: &str) -> Option> { }) } +/// Extract a UTC datetime from a record field. +/// +/// Accepts the same nested `{ date: { year, month, day }, time: { hour, minute, second } }` format +/// as `get_datetime`, but produces a `DateTime` instead. +fn get_utc_datetime(record: &ImportRecord, key: &str) -> Option> { + let ImportValue::Record(dt_record) = record.get(key)? else { + return None; + }; + let ImportValue::Record(date_rec) = dt_record.get("date")? else { + return None; + }; + let ImportValue::Record(time_rec) = dt_record.get("time")? else { + return None; + }; + + let year = get_u64(date_rec, "year")?; + let month = get_u64(date_rec, "month")?; + let day = get_u64(date_rec, "day")?; + let hour = get_u64(time_rec, "hour").unwrap_or(0); + let minute = get_u64(time_rec, "minute").unwrap_or(0); + let second = get_u64(time_rec, "second").unwrap_or(0); + + let date = Date::new( + Year::new(u16::try_from(year).ok()?).ok()?, + Month::new(u8::try_from(month).ok()?).ok()?, + Day::new(u8::try_from(day).ok()?).ok()?, + ) + .ok()?; + let time = Time::new( + Hour::new(u8::try_from(hour).ok()?).ok()?, + Minute::new(u8::try_from(minute).ok()?).ok()?, + Second::new(u8::try_from(second).ok()?).ok()?, + None, + ) + .ok()?; + + Some(DateTime { + date, + time, + marker: Utc, + }) +} + fn get_duration(record: &ImportRecord, key: &str) -> Option { let ImportValue::Record(dur_record) = record.get(key)? else { return None; @@ -436,29 +689,849 @@ fn get_priority(record: &ImportRecord) -> Option { } } -fn get_string_set(record: &ImportRecord, key: &str) -> Option> { - let ImportValue::List(items) = record.get(key)? else { - return None; - }; - let set: HashSet = items - .iter() - .filter_map(|v| match v { - ImportValue::String(s) => Some(s.clone()), - _ => None, - }) - .collect(); - if set.is_empty() { None } else { Some(set) } +fn get_string_set(record: &ImportRecord, key: &str) -> Option> { + let ImportValue::List(items) = record.get(key)? else { + return None; + }; + let set: HashSet = items + .iter() + .filter_map(|v| match v { + ImportValue::String(s) => Some(s.clone()), + _ => None, + }) + .collect(); + if set.is_empty() { None } else { Some(set) } +} + +// ── Recurrence rule builders ───────────────────────────────── + +/// Extract recurrence rules from a record. +/// +/// Handles both: +/// - `recurrence_rules`: list of rrule records (from JSCalendar import) +/// - `recur`: single rrule record (from iCalendar import) +fn get_recurrence_rules(record: &ImportRecord) -> Option> { + // Try JSCalendar-style list first. + if let Some(ImportValue::List(rules)) = record.get("recurrence_rules") { + let rrules: Vec = rules + .iter() + .filter_map(|v| { + if let ImportValue::Record(rec) = v { + record_to_rrule(rec) + } else { + None + } + }) + .collect(); + if !rrules.is_empty() { + return Some(rrules); + } + } + // Fall back to iCalendar-style single record. + if let Some(ImportValue::Record(rec)) = record.get("recur") + && let Some(rrule) = record_to_rrule(rec) + { + return Some(vec![rrule]); + } + None +} + +/// Extract excluded recurrence rules from a record. +fn get_excluded_recurrence_rules(record: &ImportRecord) -> Option> { + if let Some(ImportValue::List(rules)) = record.get("excluded_recurrence_rules") { + let rrules: Vec = rules + .iter() + .filter_map(|v| { + if let ImportValue::Record(rec) = v { + record_to_rrule(rec) + } else { + None + } + }) + .collect(); + if !rrules.is_empty() { + return Some(rrules); + } + } + None +} + +/// Convert an ImportRecord representing a recurrence rule to an RRule. +fn record_to_rrule(rec: &ImportRecord) -> Option { + let freq_str = get_str(rec, "frequency")?; + + let mut core = CoreByRules::default(); + + // BYSECOND + if let Some(ImportValue::List(by_second)) = rec.get("by_second") { + let mut set = SecondSet::default(); + for v in by_second { + if let ImportValue::Integer(n) = v + && let Ok(n8) = u8::try_from(*n) + && let Some(sec) = jscalendar::model::rrule::Second::from_repr(n8) + { + set.set(sec); + } + } + if set != SecondSet::default() { + core.by_second = Some(set); + } + } + + // BYMINUTE + if let Some(ImportValue::List(by_minute)) = rec.get("by_minute") { + let mut set = MinuteSet::default(); + for v in by_minute { + if let ImportValue::Integer(n) = v + && let Ok(n8) = u8::try_from(*n) + && let Some(min) = jscalendar::model::rrule::Minute::from_repr(n8) + { + set.set(min); + } + } + if set != MinuteSet::default() { + core.by_minute = Some(set); + } + } + + // BYHOUR + if let Some(ImportValue::List(by_hour)) = rec.get("by_hour") { + let mut set = HourSet::default(); + for v in by_hour { + if let ImportValue::Integer(n) = v + && let Ok(n8) = u8::try_from(*n) + && let Some(h) = jscalendar::model::rrule::Hour::from_repr(n8) + { + set.set(h); + } + } + if set != HourSet::default() { + core.by_hour = Some(set); + } + } + + // BYMONTH + if let Some(ImportValue::List(by_month)) = rec.get("by_month") { + let mut set = MonthSet::default(); + for v in by_month { + if let ImportValue::Integer(n) = v + && let Ok(n8) = u8::try_from(*n) + && let Ok(month) = Month::new(n8) + { + set.set(month); + } + } + if set != MonthSet::default() { + core.by_month = Some(set); + } + } + + // BYDAY + if let Some(ImportValue::List(by_day)) = rec.get("by_day") { + let mut set = WeekdayNumSet::default(); + for v in by_day { + match v { + ImportValue::String(s) => { + if let Some(wd) = str_to_weekday(s) { + set.insert(WeekdayNum { + weekday: wd, + ordinal: None, + }); + } + } + ImportValue::Record(day_rec) => { + if let Some(day_str) = get_str(day_rec, "day") + && let Some(wd) = str_to_weekday(day_str) + { + let ordinal_i64 = get_i64(day_rec, "ordinal").unwrap_or(0); + let ord = if ordinal_i64 == 0 { + None + } else { + let sign = if ordinal_i64 < 0 { + Sign::Neg + } else { + Sign::Pos + }; + u8::try_from(ordinal_i64.unsigned_abs()) + .ok() + .and_then(IsoWeek::from_index) + .map(|w| (sign, w)) + }; + set.insert(WeekdayNum { + weekday: wd, + ordinal: ord, + }); + } + } + _ => {} + } + } + if !set.is_empty() { + core.by_day = Some(set); + } + } + + // BYSETPOS + if let Some(ImportValue::List(by_set_pos)) = rec.get("by_set_pos") { + let mut set: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for v in by_set_pos { + let n = match v { + ImportValue::Integer(n) => i64::try_from(*n).ok(), + ImportValue::SignedInteger(n) => Some(*n), + _ => None, + }; + if let Some(n) = n + && let Ok(abs) = u16::try_from(n.unsigned_abs()) + && let Some(ydn) = + YearDayNum::from_signed_index(if n < 0 { Sign::Neg } else { Sign::Pos }, abs) + { + set.insert(ydn); + } + } + if !set.is_empty() { + core.by_set_pos = Some(set); + } + } + + // Helpers for frequency-specific BY rules. + let build_month_day_set = |rec: &ImportRecord| -> Option { + let ImportValue::List(by_month_day) = rec.get("by_month_day")? else { + return None; + }; + let mut set = MonthDaySet::default(); + for v in by_month_day { + let n = match v { + ImportValue::Integer(n) => i64::try_from(*n).ok(), + ImportValue::SignedInteger(n) => Some(*n), + _ => None, + }; + if let Some(n) = n + && let Ok(abs) = u8::try_from(n.unsigned_abs()) + && let Some(day) = MonthDay::from_repr(abs) + { + let sign = if n < 0 { Sign::Neg } else { Sign::Pos }; + let idx = MonthDaySetIndex::from_signed_month_day(sign, day); + set.set(idx); + } + } + if set == MonthDaySet::default() { + None + } else { + Some(set) + } + }; + + let build_year_day_set = + |rec: &ImportRecord| -> Option> { + let ImportValue::List(by_year_day) = rec.get("by_year_day")? else { + return None; + }; + let mut set: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for v in by_year_day { + let n = match v { + ImportValue::Integer(n) => i64::try_from(*n).ok(), + ImportValue::SignedInteger(n) => Some(*n), + _ => None, + }; + if let Some(n) = n + && let Ok(abs) = u16::try_from(n.unsigned_abs()) + && let Some(ydn) = YearDayNum::from_signed_index( + if n < 0 { Sign::Neg } else { Sign::Pos }, + abs, + ) + { + set.insert(ydn); + } + } + if set.is_empty() { None } else { Some(set) } + }; + + let build_week_no_set = |rec: &ImportRecord| -> Option { + let ImportValue::List(by_week_no) = rec.get("by_week_no")? else { + return None; + }; + let mut set = WeekNoSet::default(); + for v in by_week_no { + let n = match v { + ImportValue::Integer(n) => i64::try_from(*n).ok(), + ImportValue::SignedInteger(n) => Some(*n), + _ => None, + }; + if let Some(n) = n + && let Ok(abs) = u8::try_from(n.unsigned_abs()) + && let Some(week) = IsoWeek::from_index(abs) + { + let sign = if n < 0 { Sign::Neg } else { Sign::Pos }; + let idx = WeekNoSetIndex::from_signed_week(sign, week); + set.set(idx); + } + } + if set == WeekNoSet::default() { + None + } else { + Some(set) + } + }; + + let freq = match freq_str { + "secondly" => FreqByRules::Secondly(ByPeriodDayRules { + by_month_day: build_month_day_set(rec), + by_year_day: build_year_day_set(rec), + }), + "minutely" => FreqByRules::Minutely(ByPeriodDayRules { + by_month_day: build_month_day_set(rec), + by_year_day: build_year_day_set(rec), + }), + "hourly" => FreqByRules::Hourly(ByPeriodDayRules { + by_month_day: build_month_day_set(rec), + by_year_day: build_year_day_set(rec), + }), + "daily" => FreqByRules::Daily(ByMonthDayRule { + by_month_day: build_month_day_set(rec), + }), + "weekly" => FreqByRules::Weekly, + "monthly" => FreqByRules::Monthly(ByMonthDayRule { + by_month_day: build_month_day_set(rec), + }), + "yearly" => FreqByRules::Yearly(YearlyByRules { + by_month_day: build_month_day_set(rec), + by_year_day: build_year_day_set(rec), + by_week_no: build_week_no_set(rec), + }), + _ => return None, + }; + + // INTERVAL + let interval = get_u64(rec, "interval").and_then(|n| NonZero::new(n).map(Interval::new)); + + // TERMINATION (COUNT or UNTIL) + let termination = if let Some(count) = get_u64(rec, "count") { + Some(Termination::Count(count)) + } else { + get_datetime(rec, "until").map(|dt| { + Termination::Until(rfc5545_types::time::DateTimeOrDate::DateTime(DateTime { + date: dt.date, + time: dt.time, + marker: TimeFormat::Local, + })) + }) + }; + + // WKST + let week_start = get_str(rec, "week_start").and_then(str_to_weekday); + + Some(RRule { + freq, + core_by_rules: core, + interval, + termination, + week_start, + }) +} + +/// Convert a string to a Weekday. +fn str_to_weekday(s: &str) -> Option { + match s { + "monday" | "MO" => Some(Weekday::Monday), + "tuesday" | "TU" => Some(Weekday::Tuesday), + "wednesday" | "WE" => Some(Weekday::Wednesday), + "thursday" | "TH" => Some(Weekday::Thursday), + "friday" | "FR" => Some(Weekday::Friday), + "saturday" | "SA" => Some(Weekday::Saturday), + "sunday" | "SU" => Some(Weekday::Sunday), + _ => None, + } +} + +// ── Location builders ──────────────────────────────────────── + +/// Build JSCalendar locations from record fields. +/// +/// Handles: +/// - `locations`: passthrough map of location records (from JSCalendar import) +/// - `location` + `geo`: iCalendar-style fields combined into a single location entry +fn build_locations(record: &ImportRecord) -> Option, Location>> { + // Try JSCalendar-style locations map first (passthrough as JSON). + if let Some(ImportValue::Record(locs)) = record.get("locations") { + let mut map = HashMap::new(); + for (id_str, val) in locs { + if let Ok(id) = Id::new(id_str) + && let ImportValue::Record(loc_rec) = val + { + let mut loc = Location::new(); + if let Some(name) = get_str(loc_rec, "name") { + loc.set_name(name.to_string()); + } + if let Some(desc) = get_str(loc_rec, "description") { + loc.set_description(desc.to_string()); + } + if let Some(tz) = get_str(loc_rec, "time_zone") { + loc.set_time_zone(tz.to_string()); + } + if let Some(coords) = get_str(loc_rec, "coordinates") + && let Ok(geo) = GeoUri::new(coords) + { + loc.set_coordinates(geo.into()); + } + map.insert(id.into(), loc); + } + } + if !map.is_empty() { + return Some(map); + } + } + + // Fall back to iCalendar-style location/geo. + let has_location = record.contains_key("location"); + let has_geo = record.contains_key("geo"); + + if !has_location && !has_geo { + return None; + } + + let mut loc = Location::new(); + + if let Some(name) = get_str(record, "location") { + loc.set_name(name.to_string()); + } + + if let Some(ImportValue::Record(geo_rec)) = record.get("geo") { + let lat = get_str(geo_rec, "latitude").unwrap_or("0"); + let lon = get_str(geo_rec, "longitude").unwrap_or("0"); + let geo_uri_str = format!("geo:{lat},{lon}"); + if let Ok(geo) = GeoUri::new(&geo_uri_str) { + loc.set_coordinates(geo.into()); + } + } + + let id_str = "1"; + let id: Box = Id::new(id_str).unwrap().into(); + let mut map = HashMap::new(); + map.insert(id, loc); + Some(map) +} + +/// Build JSCalendar virtual locations from record fields. +fn build_virtual_locations( + record: &ImportRecord, +) -> Option, VirtualLocation>> { + let ImportValue::Record(vlocs) = record.get("virtual_locations")? else { + return None; + }; + let mut map = HashMap::new(); + for (id_str, val) in vlocs { + if let Ok(id) = Id::new(id_str) + && let ImportValue::Record(vloc_rec) = val + { + let uri_str = get_str(vloc_rec, "uri").unwrap_or("https://example.com"); + if let Ok(uri) = Uri::new(uri_str) { + let mut vloc = VirtualLocation::new(uri.into()); + if let Some(name) = get_str(vloc_rec, "name") { + vloc.set_name(name.to_string()); + } + if let Some(desc) = get_str(vloc_rec, "description") { + vloc.set_description(desc.to_string()); + } + map.insert(id.into(), vloc); + } + } + } + if map.is_empty() { None } else { Some(map) } +} + +// ── Link builders ──────────────────────────────────────────── + +/// Build JSCalendar links from record fields. +/// +/// Handles: +/// - `links`: passthrough map of link records (from JSCalendar import) +/// - `url`: single URL → link entry +/// - `attachments`: list of attachment URIs → link entries +fn build_links(record: &ImportRecord) -> Option, Link>> { + // Try JSCalendar-style links map first. + if let Some(ImportValue::Record(links_map)) = record.get("links") { + let mut map = HashMap::new(); + for (id_str, val) in links_map { + if let Ok(id) = Id::new(id_str) + && let ImportValue::Record(link_rec) = val + { + let href_str = get_str(link_rec, "href").unwrap_or("https://example.com"); + if let Ok(href) = Uri::new(href_str) { + let link = Link::new(href.into()); + map.insert(id.into(), link); + } + } + } + if !map.is_empty() { + return Some(map); + } + } + + // Build from iCalendar-style url and attachments. + let mut map: HashMap, Link> = HashMap::new(); + let mut counter = 1u32; + + if let Some(url_str) = get_str(record, "url") + && let Ok(href) = Uri::new(url_str) + { + let id_str = counter.to_string(); + if let Ok(id) = Id::new(&id_str) { + map.insert(id.into(), Link::new(href.into())); + counter += 1; + } + } + + if let Some(ImportValue::List(attachments)) = record.get("attachments") { + for att in attachments { + let uri_str = match att { + ImportValue::String(s) => Some(s.as_str()), + ImportValue::Record(rec) => get_str(rec, "uri").or_else(|| get_str(rec, "url")), + _ => None, + }; + if let Some(uri_str) = uri_str + && let Ok(href) = Uri::new(uri_str) + { + let id_str = counter.to_string(); + if let Ok(id) = Id::new(&id_str) { + map.insert(id.into(), Link::new(href.into())); + counter += 1; + } + } + } + } + + if map.is_empty() { None } else { Some(map) } +} + +// ── Relation builder ───────────────────────────────────────── + +/// Build JSCalendar relatedTo from record fields. +/// +/// Handles: +/// - `related_to` as a map (from JSCalendar import passthrough) +/// - `related_to` as a list of UID strings (from iCalendar import) +fn build_related_to(record: &ImportRecord) -> Option, Relation>> { + match record.get("related_to")? { + ImportValue::List(items) => { + // iCalendar-style: list of UID strings. + let mut map: HashMap, Relation> = HashMap::new(); + for item in items { + if let ImportValue::String(s) = item + && let Ok(uid) = Uid::new(s) + { + let relation = Relation::new(HashSet::new()); + map.insert(uid.into(), relation); + } + } + if map.is_empty() { None } else { Some(map) } + } + ImportValue::Record(rel_map) => { + // JSCalendar-style: map of uid → relation record. + let mut map: HashMap, Relation> = HashMap::new(); + for (uid_str, val) in rel_map { + if let Ok(uid) = Uid::new(uid_str) { + let mut relation_types = HashSet::new(); + if let ImportValue::Record(rel_rec) = val + && let Some(ImportValue::List(rels)) = rel_rec.get("relation") + { + for rel in rels { + if let ImportValue::String(r) = rel { + let rv = Token::>::from_str(r).unwrap(); + relation_types.insert(rv); + } + } + } + let relation = Relation::new(relation_types); + map.insert(uid.into(), relation); + } + } + if map.is_empty() { None } else { Some(map) } + } + _ => None, + } +} + +// ── Participant builders ───────────────────────────────────── + +/// Build JSCalendar participants for events from organizer/attendees/participants fields. +fn build_event_participants(record: &ImportRecord) -> Option, Participant>> { + // Try JSCalendar-style participants map first. + if let Some(ImportValue::Record(parts)) = record.get("participants") { + let mut map = HashMap::new(); + for (id_str, val) in parts { + if let Ok(id) = Id::new(id_str) + && let ImportValue::Record(part_rec) = val + { + let mut participant = Participant::new(); + if let Some(name) = get_str(part_rec, "name") { + participant.set_name(name.to_string()); + } + if let Some(email) = get_str(part_rec, "email") + && let Ok(addr) = EmailAddr::new(email) + { + participant.set_email(addr.into()); + } + map.insert(id.into(), participant); + } + } + if !map.is_empty() { + return Some(map); + } + } + + // Build from iCalendar-style organizer + attendees. + let has_organizer = record.contains_key("organizer"); + let has_attendees = record.contains_key("attendees"); + + if !has_organizer && !has_attendees { + return None; + } + + let mut map: HashMap, Participant> = HashMap::new(); + let mut counter = 1u32; + + if let Some(org_str) = get_str(record, "organizer") { + let id_str = counter.to_string(); + if let Ok(id) = Id::new(&id_str) { + let mut participant = Participant::new(); + // Set the organizer email if it's a mailto: URI. + if let Some(email) = org_str.strip_prefix("mailto:") { + if let Ok(addr) = EmailAddr::new(email) { + participant.set_email(addr.into()); + } + } else { + participant.set_name(org_str.to_string()); + } + let mut roles = HashSet::new(); + roles.insert(Token::Known(ParticipantRole::Owner)); + participant.set_roles(roles); + map.insert(id.into(), participant); + counter += 1; + } + } + + if let Some(ImportValue::List(attendees)) = record.get("attendees") { + for att in attendees { + if let ImportValue::String(s) = att { + let id_str = counter.to_string(); + if let Ok(id) = Id::new(&id_str) { + let mut participant = Participant::new(); + if let Some(email) = s.strip_prefix("mailto:") { + if let Ok(addr) = EmailAddr::new(email) { + participant.set_email(addr.into()); + } + } else { + participant.set_name(s.to_string()); + } + let mut roles = HashSet::new(); + roles.insert(Token::Known(ParticipantRole::Attendee)); + participant.set_roles(roles); + map.insert(id.into(), participant); + counter += 1; + } + } + } + } + + if map.is_empty() { None } else { Some(map) } +} + +/// Build JSCalendar participants for tasks from organizer/attendees/participants fields. +fn build_task_participants( + record: &ImportRecord, +) -> Option, TaskParticipant>> { + // Try JSCalendar-style participants map first. + if let Some(ImportValue::Record(parts)) = record.get("participants") { + let mut map = HashMap::new(); + for (id_str, val) in parts { + if let Ok(id) = Id::new(id_str) + && let ImportValue::Record(part_rec) = val + { + let mut participant = TaskParticipant::new(); + if let Some(name) = get_str(part_rec, "name") { + participant.set_name(name.to_string()); + } + if let Some(email) = get_str(part_rec, "email") + && let Ok(addr) = EmailAddr::new(email) + { + participant.set_email(addr.into()); + } + map.insert(id.into(), participant); + } + } + if !map.is_empty() { + return Some(map); + } + } + + // Build from iCalendar-style organizer + attendees. + let has_organizer = record.contains_key("organizer"); + let has_attendees = record.contains_key("attendees"); + + if !has_organizer && !has_attendees { + return None; + } + + let mut map: HashMap, TaskParticipant> = HashMap::new(); + let mut counter = 1u32; + + if let Some(org_str) = get_str(record, "organizer") { + let id_str = counter.to_string(); + if let Ok(id) = Id::new(&id_str) { + let mut participant = TaskParticipant::new(); + // Set the organizer email if it's a mailto: URI. + if let Some(email) = org_str.strip_prefix("mailto:") { + if let Ok(addr) = EmailAddr::new(email) { + participant.set_email(addr.into()); + } + } else { + participant.set_name(org_str.to_string()); + } + let mut roles = HashSet::new(); + roles.insert(Token::Known(ParticipantRole::Owner)); + participant.set_roles(roles); + map.insert(id.into(), participant); + counter += 1; + } + } + + if let Some(ImportValue::List(attendees)) = record.get("attendees") { + for att in attendees { + if let ImportValue::String(s) = att { + let id_str = counter.to_string(); + if let Ok(id) = Id::new(&id_str) { + let mut participant = TaskParticipant::new(); + if let Some(email) = s.strip_prefix("mailto:") { + if let Ok(addr) = EmailAddr::new(email) { + participant.set_email(addr.into()); + } + } else { + participant.set_name(s.to_string()); + } + let mut roles = HashSet::new(); + roles.insert(Token::Known(ParticipantRole::Attendee)); + participant.set_roles(roles); + map.insert(id.into(), participant); + counter += 1; + } + } + } + } + + if map.is_empty() { None } else { Some(map) } +} + +// ── Scheduling builders ────────────────────────────────────── + +/// Build JSCalendar replyTo from record fields. +fn build_reply_to(record: &ImportRecord) -> Option { + let ImportValue::Record(rt_rec) = record.get("reply_to")? else { + return None; + }; + + let mut reply_to = ReplyTo::new(); + if let Some(imip_str) = get_str(rt_rec, "imip") + && let Ok(addr) = CalAddress::new(imip_str) + { + reply_to.set_imip(addr.into()); + } + if let Some(web_str) = get_str(rt_rec, "web") + && let Ok(uri) = Uri::new(web_str) + { + reply_to.set_web(uri.into()); + } + + Some(reply_to) +} + +/// Build JSCalendar requestStatus from record fields. +/// +/// Handles: +/// - `request_status`: single record (from JSCalendar) +/// - `request_statuses`: list of status strings (from iCalendar import, takes first) +fn build_request_status( + record: &ImportRecord, +) -> Option { + use jscalendar::model::request_status::RequestStatus; + + // Try JSCalendar-style request_status record first. + if let Some(ImportValue::Record(rs_rec)) = record.get("request_status") { + let code = parse_status_code(get_str(rs_rec, "code")?)?; + let description = get_str(rs_rec, "description") + .unwrap_or("") + .to_string() + .into_boxed_str(); + return Some(RequestStatus { + code, + description, + exception_data: None, + }); + } + + // Fall back to iCalendar-style request_statuses (list of status strings). + // JSCalendar only supports a single requestStatus, so take the first. + if let Some(ImportValue::List(statuses)) = record.get("request_statuses") { + for status in statuses { + if let ImportValue::String(s) = status { + // Parse "code;description;data" format. + let parts: Vec<&str> = s.splitn(3, ';').collect(); + if let Some(code_str) = parts.first() + && let Some(code) = parse_status_code(code_str) + { + let description = parts.get(1).unwrap_or(&"").to_string().into_boxed_str(); + let exception_data = parts.get(2).map(|d| d.to_string().into_boxed_str()); + return Some(RequestStatus { + code, + description, + exception_data, + }); + } + } + } + } + + None +} + +/// Parse a dotted status code like "2.0" or "3.1.2" into a StatusCode. +fn parse_status_code(s: &str) -> Option { + use jscalendar::model::request_status::{Class, StatusCode}; + + let mut parts = s.split('.'); + let class_n: u8 = parts.next()?.parse().ok()?; + let major: u8 = parts.next()?.parse().ok()?; + let minor: Option = parts.next().and_then(|p| p.parse().ok()); + + let class = Class::from_u8(class_n)?; + Some(StatusCode { + class, + major, + minor, + }) } // ── Vendor properties ──────────────────────────────────────── /// Collect record fields not in the known set into a JSON object for vendor_property. -fn collect_vendor_properties(record: &ImportRecord, known: &[&str]) -> Map { +/// +/// Also emits warnings for fields that are not vendor-prefixed (i.e. don't contain a colon). +fn collect_vendor_properties( + record: &ImportRecord, + known: &[&str], + kind: &str, + warnings: &mut Vec, +) -> Map { let mut obj = Map::new(); for (key, value) in record { if known.contains(&key.as_str()) { continue; } + // r[impl model.export.jscalendar.unknown] + if !key.contains(':') { + warnings.push(format!( + "unrecognised non-vendor field '{key}' on {kind} record" + )); + } obj.insert(key.clone(), import_value_to_json(value)); } obj @@ -904,4 +1977,333 @@ mod tests { assert_eq!(task.percent_complete().unwrap().get(), 50); assert_eq!(task.progress().as_ref().unwrap().to_string(), "in-process"); } + + #[test] + fn created_and_updated_exported() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ("created", make_datetime(2025, 6, 1, 12, 0, 0)), + ("last_modified", make_datetime(2025, 12, 25, 8, 30, 0)), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + assert_eq!(entries[0]["created"], "2025-06-01T12:00:00Z"); + assert_eq!(entries[0]["updated"], "2025-12-25T08:30:00Z"); + } + + #[test] + fn sequence_exported() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ("sequence", ImportValue::Integer(3)), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + assert_eq!(entries[0]["sequence"], 3); + } + + #[test] + fn recurrence_id_exported() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ("recurrence_id", make_datetime(2026, 1, 1, 0, 0, 0)), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + assert_eq!(entries[0]["recurrenceId"], "2026-01-01T00:00:00"); + } + + #[test] + fn location_and_geo_exported_as_locations() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ("location", ImportValue::String("Conference Room A".into())), + ( + "geo", + ImportValue::Record(make_record(&[ + ("latitude", ImportValue::String("37.7749".into())), + ("longitude", ImportValue::String("-122.4194".into())), + ])), + ), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + let locations = entries[0]["locations"].as_object().unwrap(); + assert_eq!(locations.len(), 1); + let loc = locations.values().next().unwrap(); + assert_eq!(loc["name"], "Conference Room A"); + assert_eq!(loc["coordinates"], "geo:37.7749,-122.4194"); + } + + #[test] + fn url_exported_as_link() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ( + "url", + ImportValue::String("https://example.com/event".into()), + ), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + let links = entries[0]["links"].as_object().unwrap(); + assert_eq!(links.len(), 1); + let link = links.values().next().unwrap(); + assert_eq!(link["@type"], "Link"); + assert_eq!(link["href"], "https://example.com/event"); + } + + #[test] + fn related_to_list_exported() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ( + "related_to", + ImportValue::List(vec![ImportValue::String( + "b9ef7684-1585-5a7e-b827-144b66551111".into(), + )]), + ), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + let related = entries[0]["relatedTo"].as_object().unwrap(); + assert!(related.contains_key("b9ef7684-1585-5a7e-b827-144b66551111")); + } + + #[test] + fn organizer_and_attendees_exported_as_participants() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ( + "organizer", + ImportValue::String("mailto:org@example.com".into()), + ), + ( + "attendees", + ImportValue::List(vec![ImportValue::String("mailto:att@example.com".into())]), + ), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + let participants = entries[0]["participants"].as_object().unwrap(); + assert_eq!(participants.len(), 2); + + // Find the organizer and attendee by role. + let mut found_owner = false; + let mut found_attendee = false; + for part in participants.values() { + if let Some(roles) = part["roles"].as_object() { + if roles.contains_key("owner") { + found_owner = true; + } + if roles.contains_key("attendee") { + found_attendee = true; + } + } + } + assert!(found_owner, "organizer should have owner role"); + assert!(found_attendee, "attendee should have attendee role"); + } + + #[test] + fn recurrence_rule_exported() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let rrule = ImportValue::Record(make_record(&[ + ("frequency", ImportValue::String("weekly".into())), + ("interval", ImportValue::Integer(2)), + ("count", ImportValue::Integer(10)), + ])); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ("recur", rrule), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + let rules = entries[0]["recurrenceRules"].as_array().unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0]["@type"], "RecurrenceRule"); + assert_eq!(rules[0]["frequency"], "weekly"); + assert_eq!(rules[0]["interval"], 2); + assert_eq!(rules[0]["count"], 10); + } + + #[test] + fn unknown_non_vendor_field_warns() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ("transparency", ImportValue::String("opaque".into())), + ])); + + let mut warnings = vec![]; + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut warnings).unwrap(); + + assert!( + warnings + .iter() + .any(|w| w.contains("transparency") && w.contains("event")), + "should warn about unrecognised non-vendor field: {:?}", + warnings + ); + } + + #[test] + fn vendor_prefixed_fields_no_warning() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ( + "com.example:custom", + ImportValue::String("vendor-value".into()), + ), + ])); + + let mut warnings = vec![]; + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut warnings).unwrap(); + + assert!( + warnings.is_empty(), + "vendor-prefixed fields should not warn: {:?}", + warnings + ); + } + + #[test] + fn reply_to_exported() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ( + "reply_to", + ImportValue::Record(make_record(&[( + "imip", + ImportValue::String("mailto:reply@example.com".into()), + )])), + ), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + assert_eq!(entries[0]["replyTo"]["imip"], "mailto:reply@example.com"); + } + + #[test] + fn method_exported() { + let cal = make_cal("550e8400-e29b-41d4-a716-446655440000"); + let event = ImportValue::Record(make_record(&[ + ("type", ImportValue::String("event".into())), + ( + "uid", + ImportValue::String("a8df6573-0474-496d-8496-033ad45d7fea".into()), + ), + ("start", make_datetime(2026, 1, 1, 0, 0, 0)), + ("method", ImportValue::String("request".into())), + ])); + + let mut result = String::new(); + emit_jscalendar(&mut result, &cal, &[event], &mut vec![]).unwrap(); + let parsed: Json = serde_json::from_str(&result).unwrap(); + + let entries = parsed["entries"].as_array().unwrap(); + assert_eq!(entries[0]["method"], "REQUEST"); + } } diff --git a/examples/icloud-holidays/main.gnomon b/examples/icloud-holidays/main.gnomon index d18d1eb..93899d6 100644 --- a/examples/icloud-holidays/main.gnomon +++ b/examples/icloud-holidays/main.gnomon @@ -1,6 +1,16 @@ ;; a collection of several iCloud holiday calendars -let us-holidays = import in -let ch-holidays = import in +;; imported calendars +let us-holidays = import +let ch-holidays = import -us-holidays ++ ch-holidays +;; fixed UIDs +let us-uuid = "3bf625c9-232b-40a6-90d8-19587356138c" +let ch-uuid = "4beb2d81-cee9-4d9c-94ec-db0359ff02ca" + +in + +[ + us-holidays[0] // { uid: us-uuid }, + ch-holidays[0] // { uid: ch-uuid }, +] diff --git a/spec/gnomon.md b/spec/gnomon.md index 38912be..60b811f 100644 --- a/spec/gnomon.md +++ b/spec/gnomon.md @@ -1540,7 +1540,7 @@ When an import source is in a foreign format, it MUST be translated into the Gno > r[model.import.jscalendar.types] > A JSCalendar import MUST translate `Event` objects into event records and `Task` objects into task records. `Group` objects MUST be flattened: each entry in the group is translated individually. -> r[model.import.jscalendar.event] +> r[model.import.jscalendar.event+2] > A JSCalendar `Event` MUST be translated to a record with the following field mapping: > > | JSCalendar Property | Gnomon Field | Type | @@ -1561,8 +1561,22 @@ When an import source is in a foreign format, it MUST be translated into the Gno > | `showWithoutTime` | `show_without_time` | boolean | > | `categories` | `categories` | list of strings | > | `keywords` | `keywords` | list of strings | - -> r[model.import.jscalendar.task] +> | `created` | `created` | UTC datetime record | +> | `updated` | `updated` | UTC datetime record | +> | `sequence` | `sequence` | integer | +> | `method` | `method` | string | +> | `recurrenceId` | `recurrence_id` | datetime record | +> | `recurrenceRules` | `recurrence_rules` | list of recurrence rule records | +> | `excludedRecurrenceRules` | `excluded_recurrence_rules` | list of recurrence rule records | +> | `locations` | `locations` | id-keyed map of location records | +> | `virtualLocations` | `virtual_locations` | id-keyed map of virtual location records | +> | `links` | `links` | id-keyed map of link records | +> | `relatedTo` | `related_to` | uid-keyed map of relation records | +> | `participants` | `participants` | id-keyed map of participant records | +> | `replyTo` | `reply_to` | record | +> | `requestStatus` | `request_status` | request status record | + +> r[model.import.jscalendar.task+2] > A JSCalendar `Task` MUST be translated to a record with the following field mapping: > > | JSCalendar Property | Gnomon Field | Type | @@ -1585,6 +1599,20 @@ When an import source is in a foreign format, it MUST be translated into the Gno > | `showWithoutTime` | `show_without_time` | boolean | > | `categories` | `categories` | list of strings | > | `keywords` | `keywords` | list of strings | +> | `created` | `created` | UTC datetime record | +> | `updated` | `updated` | UTC datetime record | +> | `sequence` | `sequence` | integer | +> | `method` | `method` | string | +> | `recurrenceId` | `recurrence_id` | datetime record | +> | `recurrenceRules` | `recurrence_rules` | list of recurrence rule records | +> | `excludedRecurrenceRules` | `excluded_recurrence_rules` | list of recurrence rule records | +> | `locations` | `locations` | id-keyed map of location records | +> | `virtualLocations` | `virtual_locations` | id-keyed map of virtual location records | +> | `links` | `links` | id-keyed map of link records | +> | `relatedTo` | `related_to` | uid-keyed map of relation records | +> | `participants` | `participants` | id-keyed map of participant records | +> | `replyTo` | `reply_to` | record | +> | `requestStatus` | `request_status` | request status record | > r[model.import.jscalendar.vendor] > Vendor-specific properties (property names not defined by RFC 9553) on JSCalendar objects MUST be preserved in the translated record. JSON values MUST be translated recursively: objects to records, arrays to lists, strings to strings, numbers to integers or signed integers, booleans to booleans, and null to `undefined`. @@ -1630,15 +1658,18 @@ When compiling a Gnomon calendar to a foreign format, each validated `Calendar` > r[model.export.jscalendar.calendar+2] > A JSCalendar export MUST produce one JSCalendar Group object per calendar. Each Group MUST contain the calendar's entries and calendar-level properties (uid, title, etc.). If there is a single calendar, the output MUST be the Group object. If there are multiple calendars, the output MUST be a JSON array of Group objects. -> r[model.export.jscalendar.event] -> An event record MUST be translated to a JSCalendar Event object (with `@type` set to `"Event"`) using the inverse of the JSCalendar import event mapping table. Gnomon field names MUST be converted to their camelCase JSCalendar equivalents (e.g. `time_zone` → `timeZone`, `free_busy_status` → `freeBusyStatus`). +> r[model.export.jscalendar.event+2] +> An event record MUST be translated to a JSCalendar Event object (with `@type` set to `"Event"`) using the inverse of the JSCalendar import event mapping table. Gnomon field names MUST be converted to their camelCase JSCalendar equivalents (e.g. `time_zone` → `timeZone`, `free_busy_status` → `freeBusyStatus`). All standardized JSCalendar properties (metadata, recurrence, location, participant, link, and scheduling properties) MUST be mapped to their proper JSCalendar types. -> r[model.export.jscalendar.task] -> A task record MUST be translated to a JSCalendar Task object (with `@type` set to `"Task"`) using the inverse of the JSCalendar import task mapping table. +> r[model.export.jscalendar.task+2] +> A task record MUST be translated to a JSCalendar Task object (with `@type` set to `"Task"`) using the inverse of the JSCalendar import task mapping table. All standardized JSCalendar properties MUST be mapped as for events. > r[model.export.jscalendar.vendor] > Record fields that do not correspond to any defined JSCalendar property MUST be emitted as vendor-specific properties in the JSON output. Values MUST be translated recursively using the inverse of the import JSON value translation. +> r[model.export.jscalendar.unknown] +> Record fields that do not correspond to any defined JSCalendar property mapping and do not follow the vendor property naming convention (containing a colon, e.g. `com.example:custom`) SHOULD produce a warning, since the field may be a misspelling of a mapped property. + ### Shape-checking Shape-checking is the process of validating that a Gnomon value conforms to a recognized shape (calendar, event, task, recurrence rule, or any other record type defined in this specification). It enforces mandatory field presence, field value types, and value restrictions.