From f8c302b5ed9ee31c6e28e1c38c3efbf2f2157812 Mon Sep 17 00:00:00 2001 From: Oliver Wooding Date: Sat, 14 Mar 2026 13:53:02 +0100 Subject: [PATCH 1/4] fix(gnomon-export): add missing JSCalendar property mappings and warnings API Fixes #50 and #54 together. The JSCalendar exporter now maps all standardised properties (created, updated, sequence, method, recurrenceId, recurrenceRules, excludedRecurrenceRules, locations, virtualLocations, links, relatedTo, participants, replyTo, requestStatus) to proper JSCalendar model types instead of falling through to vendor properties. Also adds a `warnings: &mut Vec` parameter to `emit_jscalendar` (matching `emit_icalendar`) and warns about unrecognised non-vendor fields. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/gnomon-export/Cargo.toml | 1 + crates/gnomon-export/src/jscal.rs | 1479 ++++++++++++++++++++++++++++- spec/gnomon.md | 45 +- 4 files changed, 1489 insertions(+), 37 deletions(-) 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..3e2cd77 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::{ + ByMonthDayRule, ByPeriodDayRules, CoreByRules, FreqByRules, HourSet, Interval, MinuteSet, + MonthDay, MonthDaySet, MonthDaySetIndex, MonthSet, RRule, SecondSet, Termination, WeekNoSet, + WeekNoSetIndex, WeekdayNum, YearDayNum, YearlyByRules, +}; +use jscalendar::model::rrule::weekday_num_set::WeekdayNumSet; 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") { + if 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") { + if 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,861 @@ 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") { + if 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 if let Some(dt) = get_datetime(rec, "until") { + Some(Termination::Until( + rfc5545_types::time::DateTimeOrDate::DateTime(DateTime { + date: dt.date, + time: dt.time, + marker: TimeFormat::Local, + }), + )) + } else { + None + }; + + // 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) { + if 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") { + if 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) { + if 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) { + if 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") { + if 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 { + if 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 { + if 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 { + if 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) { + if 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") { + if 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) { + if 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") { + if 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") { + if let Ok(addr) = CalAddress::new(imip_str) { + reply_to.set_imip(addr.into()); + } + } + if let Some(web_str) = get_str(rt_rec, "web") { + if 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 +1989,338 @@ 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/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. From 7ef7eaadd3a0e1c13f7163405d7268ba4e57ac60 Mon Sep 17 00:00:00 2001 From: Oliver Wooding Date: Sat, 14 Mar 2026 13:59:03 +0100 Subject: [PATCH 2/4] applied clippy autofix --- crates/gnomon-export/src/jscal.rs | 93 ++++++++++++------------------- 1 file changed, 36 insertions(+), 57 deletions(-) diff --git a/crates/gnomon-export/src/jscal.rs b/crates/gnomon-export/src/jscal.rs index 3e2cd77..51ab766 100644 --- a/crates/gnomon-export/src/jscal.rs +++ b/crates/gnomon-export/src/jscal.rs @@ -217,11 +217,10 @@ fn build_event(record: &ImportRecord, warnings: &mut Vec) -> Result>::from_str(s).unwrap(); event.set_method(m); @@ -409,11 +408,10 @@ fn build_task(record: &ImportRecord, warnings: &mut Vec) -> Result>::from_str(s).unwrap(); task.set_method(m); @@ -728,11 +726,10 @@ fn get_recurrence_rules(record: &ImportRecord) -> Option> { } } // Fall back to iCalendar-style single record. - if let Some(ImportValue::Record(rec)) = record.get("recur") { - if let Some(rrule) = record_to_rrule(rec) { + if let Some(ImportValue::Record(rec)) = record.get("recur") + && let Some(rrule) = record_to_rrule(rec) { return Some(vec![rrule]); } - } None } @@ -1008,17 +1005,13 @@ fn record_to_rrule(rec: &ImportRecord) -> Option { // TERMINATION (COUNT or UNTIL) let termination = if let Some(count) = get_u64(rec, "count") { Some(Termination::Count(count)) - } else if let Some(dt) = get_datetime(rec, "until") { - Some(Termination::Until( + } else { get_datetime(rec, "until").map(|dt| Termination::Until( rfc5545_types::time::DateTimeOrDate::DateTime(DateTime { date: dt.date, time: dt.time, marker: TimeFormat::Local, }), - )) - } else { - None - }; + )) }; // WKST let week_start = get_str(rec, "week_start").and_then(str_to_weekday); @@ -1058,8 +1051,8 @@ fn build_locations(record: &ImportRecord) -> Option, Location Option, Location Option, Link>> { 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) { - if let ImportValue::Record(link_rec) = val { + 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); @@ -1174,15 +1163,14 @@ fn build_links(record: &ImportRecord) -> Option, Link>> { let mut map: HashMap, Link> = HashMap::new(); let mut counter = 1u32; - if let Some(url_str) = get_str(record, "url") { - if let Ok(href) = Uri::new(url_str) { + 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 { @@ -1191,15 +1179,14 @@ fn build_links(record: &ImportRecord) -> Option, Link>> { ImportValue::Record(rec) => get_str(rec, "uri").or_else(|| get_str(rec, "url")), _ => None, }; - if let Some(uri_str) = uri_str { - if let Ok(href) = Uri::new(uri_str) { + 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; } } - } } } @@ -1219,12 +1206,11 @@ fn build_related_to(record: &ImportRecord) -> Option, Relation< // iCalendar-style: list of UID strings. let mut map: HashMap, Relation> = HashMap::new(); for item in items { - if let ImportValue::String(s) = item { - if let Ok(uid) = Uid::new(s) { + 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) } } @@ -1234,8 +1220,8 @@ fn build_related_to(record: &ImportRecord) -> Option, Relation< 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 { - if let Some(ImportValue::List(rels)) = rel_rec.get("relation") { + 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 = @@ -1244,7 +1230,6 @@ fn build_related_to(record: &ImportRecord) -> Option, Relation< } } } - } let relation = Relation::new(relation_types); map.insert(uid.into(), relation); } @@ -1265,20 +1250,18 @@ fn build_event_participants( 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) { - if let ImportValue::Record(part_rec) = val { + 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") { - if let Ok(addr) = EmailAddr::new(email) { + 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); @@ -1350,20 +1333,18 @@ fn build_task_participants( 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) { - if let ImportValue::Record(part_rec) = val { + 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") { - if let Ok(addr) = EmailAddr::new(email) { + 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); @@ -1436,16 +1417,14 @@ fn build_reply_to(record: &ImportRecord) -> Option { }; let mut reply_to = ReplyTo::new(); - if let Some(imip_str) = get_str(rt_rec, "imip") { - if let Ok(addr) = CalAddress::new(imip_str) { + 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") { - if let Ok(uri) = Uri::new(web_str) { + 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) } From 1ded49fe26c74798109b0cc1a86449a007aae36f Mon Sep 17 00:00:00 2001 From: Oliver Wooding Date: Sat, 14 Mar 2026 13:59:20 +0100 Subject: [PATCH 3/4] formatting --- crates/gnomon-export/src/jscal.rs | 244 +++++++++++++++--------------- 1 file changed, 124 insertions(+), 120 deletions(-) diff --git a/crates/gnomon-export/src/jscal.rs b/crates/gnomon-export/src/jscal.rs index 51ab766..2d64013 100644 --- a/crates/gnomon-export/src/jscal.rs +++ b/crates/gnomon-export/src/jscal.rs @@ -10,12 +10,12 @@ 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::rrule::weekday_num_set::WeekdayNumSet; use jscalendar::model::set::{ Color, EventStatus, FreeBusyStatus, Method, ParticipantRole, Percent, Priority, Privacy, RelationValue, TaskProgress, @@ -212,15 +212,16 @@ fn build_event(record: &ImportRecord, warnings: &mut Vec) -> Result>::from_str(s).unwrap(); event.set_method(m); @@ -403,15 +404,16 @@ fn build_task(record: &ImportRecord, warnings: &mut Vec) -> Result>::from_str(s).unwrap(); task.set_method(m); @@ -727,9 +729,10 @@ fn get_recurrence_rules(record: &ImportRecord) -> Option> { } // 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]); - } + && let Some(rrule) = record_to_rrule(rec) + { + return Some(vec![rrule]); + } None } @@ -923,8 +926,7 @@ fn record_to_rrule(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(); + 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(), @@ -1005,13 +1007,15 @@ fn record_to_rrule(rec: &ImportRecord) -> Option { // 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 { + } 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); @@ -1052,23 +1056,25 @@ fn build_locations(record: &ImportRecord) -> Option, Location Option, Link>> { 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); - } + && 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); @@ -1164,13 +1172,14 @@ fn build_links(record: &ImportRecord) -> Option, Link>> { 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; - } + && 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 { @@ -1180,13 +1189,14 @@ fn build_links(record: &ImportRecord) -> Option, Link>> { _ => 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; - } + && 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; } + } } } @@ -1207,10 +1217,11 @@ fn build_related_to(record: &ImportRecord) -> Option, Relation< 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); - } + && 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) } } @@ -1221,15 +1232,15 @@ fn build_related_to(record: &ImportRecord) -> Option, Relation< 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 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); } @@ -1243,25 +1254,25 @@ fn build_related_to(record: &ImportRecord) -> Option, Relation< // ── Participant builders ───────────────────────────────────── /// Build JSCalendar participants for events from organizer/attendees/participants fields. -fn build_event_participants( - record: &ImportRecord, -) -> Option, Participant>> { +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); + && 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); @@ -1334,17 +1345,19 @@ fn build_task_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); + && 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); @@ -1418,13 +1431,15 @@ fn build_reply_to(record: &ImportRecord) -> Option { 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()); - } + && 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()); - } + && let Ok(uri) = Uri::new(web_str) + { + reply_to.set_web(uri.into()); + } Some(reply_to) } @@ -1463,11 +1478,7 @@ fn build_request_status( 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 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, @@ -1483,9 +1494,7 @@ fn build_request_status( } /// Parse a dotted status code like "2.0" or "3.1.2" into a StatusCode. -fn parse_status_code( - s: &str, -) -> Option { +fn parse_status_code(s: &str) -> Option { use jscalendar::model::request_status::{Class, StatusCode}; let mut parts = s.split('.'); @@ -2137,9 +2146,7 @@ mod tests { ), ( "attendees", - ImportValue::List(vec![ImportValue::String( - "mailto:att@example.com".into(), - )]), + ImportValue::List(vec![ImportValue::String("mailto:att@example.com".into())]), ), ])); @@ -2276,10 +2283,7 @@ mod tests { 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" - ); + assert_eq!(entries[0]["replyTo"]["imip"], "mailto:reply@example.com"); } #[test] From fe2403150b365484e3475de7f6300757b0347fb9 Mon Sep 17 00:00:00 2001 From: Oliver Wooding Date: Sat, 14 Mar 2026 14:05:22 +0100 Subject: [PATCH 4/4] updated icloud-holidays example for JSCalendar export --- examples/icloud-holidays/main.gnomon | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 }, +]