use crate::model::item::{Alarm, AlarmTrigger, DateType, RawProperty, Task, TaskStatus};
use chrono::{Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
use icalendar::{Calendar, CalendarComponent, Component, Event, Todo, TodoStatus};
use uuid::Uuid;
const HANDLED_KEYS: &[&str] = &[
"UID",
"SUMMARY",
"DESCRIPTION",
"STATUS",
"PRIORITY",
"DUE",
"DTSTART",
"RRULE",
"DURATION",
"X-ESTIMATED-DURATION",
"PERCENT-COMPLETE",
"CATEGORIES",
"RELATED-TO",
"DTSTAMP",
"LAST-MODIFIED",
"SEQUENCE",
"PRODID",
"VERSION",
"CALSCALE",
"RECURRENCE-ID",
"LOCATION",
"URL",
"GEO",
"X-CFAIT-CREATE-EVENT",
"X-TIME-SPENT",
"X-LAST-START",
"X-CFAIT-SESSION",
"EXDATE",
"X-CFAIT-ESTIMATED-DURATION-MAX",
"X-CFAIT-FUZZY-START",
"X-CFAIT-FUZZY-DUE",
"X-CFAIT-COLLAPSED",
];
pub struct IcsAdapter;
impl IcsAdapter {
fn fix_rrule_until(rrule: &str, is_time: bool) -> String {
let parts: Vec<&str> = rrule.split(';').collect();
let mut new_parts = Vec::new();
for part in parts {
if let Some(until_val) = part.strip_prefix("UNTIL=") {
if is_time {
if until_val.len() == 8 && !until_val.contains('T') {
new_parts.push(format!("UNTIL={}T235959Z", until_val));
} else if until_val.contains('T') && !until_val.ends_with('Z') {
new_parts.push(format!("UNTIL={}Z", until_val));
} else {
new_parts.push(part.to_string());
}
} else {
if let Some(t_idx) = until_val.find('T') {
new_parts.push(format!("UNTIL={}", &until_val[..t_idx]));
} else {
new_parts.push(part.to_string());
}
}
} else {
new_parts.push(part.to_string());
}
}
new_parts.join(";")
}
pub fn to_ics(task: &Task) -> String {
let mut todo = Todo::new();
todo.add_property("UID", &task.uid);
todo.summary(&task.summary);
if !task.description.is_empty() {
todo.description(&task.description);
}
todo.timestamp(Utc::now());
todo.add_property("SEQUENCE", task.sequence.to_string());
if let Some(loc) = &task.location {
todo.add_property("LOCATION", loc);
}
if let Some(u) = &task.url {
todo.add_property("URL", u);
}
if let Some(g) = &task.geo {
let geo_val: String = g.replace(',', ";");
todo.add_property("GEO", &geo_val);
}
match task.status {
TaskStatus::NeedsAction => todo.status(TodoStatus::NeedsAction),
TaskStatus::InProcess => todo.status(TodoStatus::InProcess),
TaskStatus::Completed => todo.status(TodoStatus::Completed),
TaskStatus::Cancelled => todo.status(TodoStatus::Cancelled),
};
if let Some(pc) = task.percent_complete {
todo.percent_complete(pc);
}
let mut emit_dtstart = task.dtstart.clone();
let mut emit_due = task.due.clone();
if let (Some(start), Some(due)) = (&emit_dtstart, &emit_due) {
let start_is_time = matches!(start, DateType::Specific(_));
let due_is_time = matches!(due, DateType::Specific(_));
if start_is_time != due_is_time {
let midnight = chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap();
if !start_is_time {
emit_dtstart =
Some(DateType::Specific(start.to_utc_with_default_time(midnight)));
}
if !due_is_time {
emit_due = Some(DateType::Specific(due.to_utc_with_default_time(midnight)));
}
}
}
let dtstart_is_time = emit_dtstart
.as_ref()
.map(|d| matches!(d, DateType::Specific(_)))
.or_else(|| {
emit_due
.as_ref()
.map(|d| matches!(d, DateType::Specific(_)))
})
.unwrap_or(false);
let default_utc_time = if let Some(DateType::Specific(dt)) = &emit_dtstart {
dt.time()
} else if let Some(DateType::Specific(dt)) = &emit_due {
dt.time()
} else {
chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()
};
if let Some(dt) = &emit_dtstart {
match dt {
DateType::AllDay(d) => {
let mut p = icalendar::Property::new("DTSTART", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_property(p);
}
DateType::Specific(t) => {
todo.add_property("DTSTART", t.format("%Y%m%dT%H%M%SZ").to_string());
}
DateType::Month(y, m) => {
let d = NaiveDate::from_ymd_opt(*y, *m, 1).unwrap();
let mut p = icalendar::Property::new("DTSTART", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_property(p);
todo.add_property("X-CFAIT-FUZZY-START", format!("{:04}-{:02}", y, m));
}
DateType::Year(y) => {
let d = NaiveDate::from_ymd_opt(*y, 1, 1).unwrap();
let mut p = icalendar::Property::new("DTSTART", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_property(p);
todo.add_property("X-CFAIT-FUZZY-START", format!("{:04}", y));
}
}
}
if let Some(dt) = &emit_due {
match dt {
DateType::AllDay(d) => {
let mut p = icalendar::Property::new("DUE", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_property(p);
}
DateType::Specific(t) => {
todo.add_property("DUE", t.format("%Y%m%dT%H%M%SZ").to_string());
}
DateType::Month(y, m) => {
let next_m = if *m == 12 { 1 } else { *m + 1 };
let next_y = if *m == 12 { *y + 1 } else { *y };
let d = NaiveDate::from_ymd_opt(next_y, next_m, 1)
.unwrap()
.pred_opt()
.unwrap();
let mut p = icalendar::Property::new("DUE", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_property(p);
todo.add_property("X-CFAIT-FUZZY-DUE", format!("{:04}-{:02}", y, m));
}
DateType::Year(y) => {
let d = NaiveDate::from_ymd_opt(*y, 12, 31).unwrap();
let mut p = icalendar::Property::new("DUE", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_property(p);
todo.add_property("X-CFAIT-FUZZY-DUE", format!("{:04}", y));
}
}
}
if let Some(mins) = task.estimated_duration {
let is_all_day_start = emit_dtstart
.as_ref()
.map(|dt| matches!(dt, DateType::AllDay(_)))
.unwrap_or(false);
if emit_due.is_some() || is_all_day_start {
todo.add_property("X-ESTIMATED-DURATION", format!("PT{}M", mins));
} else {
todo.add_property("DURATION", format!("PT{}M", mins));
}
if let Some(max) = task.estimated_duration_max {
todo.add_property("X-CFAIT-ESTIMATED-DURATION-MAX", format!("PT{}M", max));
}
}
if task.priority > 0 {
let prio = if task.priority > 9 { 9 } else { task.priority };
todo.priority(prio.into());
}
if let Some(rrule) = &task.rrule {
let fixed_rrule = Self::fix_rrule_until(rrule, dtstart_is_time);
todo.add_property("RRULE", &fixed_rrule);
}
for ex in &task.exdates {
let is_time = matches!(ex, DateType::Specific(_));
if dtstart_is_time && !is_time {
let date = ex.to_date_naive();
let dt = Utc.from_utc_datetime(&date.and_time(default_utc_time));
todo.append_multi_property(icalendar::Property::new(
"EXDATE",
dt.format("%Y%m%dT%H%M%SZ").to_string(),
));
} else if !dtstart_is_time && is_time {
let date = ex.to_date_naive();
let mut p = icalendar::Property::new("EXDATE", date.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_multi_property(p);
} else {
match ex {
DateType::AllDay(d) => {
let mut p =
icalendar::Property::new("EXDATE", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_multi_property(p);
}
DateType::Specific(dt) => {
todo.append_multi_property(icalendar::Property::new(
"EXDATE",
dt.format("%Y%m%dT%H%M%SZ").to_string(),
));
}
DateType::Month(y, m) => {
let d = NaiveDate::from_ymd_opt(*y, *m, 1).unwrap();
let mut p =
icalendar::Property::new("EXDATE", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_multi_property(p);
}
DateType::Year(y) => {
let d = NaiveDate::from_ymd_opt(*y, 1, 1).unwrap();
let mut p =
icalendar::Property::new("EXDATE", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
todo.append_multi_property(p);
}
}
}
}
if let Some(p_uid) = &task.parent_uid {
let p_uid_str: String = p_uid.as_str().into();
let prop = icalendar::Property::new("RELATED-TO", &p_uid_str);
todo.append_multi_property(prop);
}
for dep_uid in &task.dependencies {
let mut prop = icalendar::Property::new("RELATED-TO", dep_uid);
prop.add_parameter("RELTYPE", "DEPENDS-ON");
todo.append_multi_property(prop);
}
for related_uid in &task.related_to {
let mut prop = icalendar::Property::new("RELATED-TO", related_uid);
prop.add_parameter("RELTYPE", "SIBLING");
todo.append_multi_property(prop);
}
if let Some(create_event) = task.create_event {
todo.add_property(
"X-CFAIT-CREATE-EVENT",
if create_event { "TRUE" } else { "FALSE" },
);
}
if task.collapsed {
todo.add_property("X-CFAIT-COLLAPSED", "TRUE");
}
if task.time_spent_seconds > 0 {
todo.add_property("X-TIME-SPENT", task.time_spent_seconds.to_string());
}
if let Some(ts) = task.last_started_at {
todo.add_property("X-LAST-START", ts.to_string());
}
for raw in &task.unmapped_properties {
let mut prop = icalendar::Property::new(&raw.key, &raw.value);
for (k, v) in &raw.params {
prop.add_parameter(k, v);
}
todo.append_property(prop);
}
let mut calendar = Calendar::new();
calendar.push(todo);
let mut ics = calendar.to_string();
if !task.sessions.is_empty() {
let mut session_lines = String::new();
for session in &task.sessions {
session_lines.push_str(&format!(
"X-CFAIT-SESSION:{}/{}\r\n",
session.start, session.end
));
}
if let Some(idx) = ics.rfind("END:VTODO") {
let (start, end) = ics.split_at(idx);
let mut buffer = String::with_capacity(ics.len() + session_lines.len());
buffer.push_str(start);
buffer.push_str(&session_lines);
buffer.push_str(end);
ics = buffer;
}
}
if !task.categories.is_empty() {
let escaped_cats: Vec<String> = task
.categories
.iter()
.map(|c: &String| {
c.replace('\\', "\\\\")
.replace(',', "\\,")
.replace('\n', "\\n")
.replace('\r', "")
})
.collect();
let cat_line = format!("CATEGORIES:{}", escaped_cats.join(","));
if let Some(idx) = ics.rfind("END:VTODO") {
let (start, end) = ics.split_at(idx);
let mut buffer = String::with_capacity(ics.len() + cat_line.len() + 2);
buffer.push_str(start);
buffer.push_str(&cat_line);
buffer.push_str("\r\n");
buffer.push_str(end);
ics = buffer;
}
}
if !task.alarms.is_empty()
&& let Some(idx) = ics.rfind("END:VTODO")
{
let (start, end) = ics.split_at(idx);
let mut buffer = String::with_capacity(ics.len() + 1024);
buffer.push_str(start);
for alarm in &task.alarms {
buffer.push_str("BEGIN:VALARM\r\n");
let safe_uid = alarm.uid.replace(['\n', '\r'], "");
buffer.push_str(&format!("UID:{}\r\n", safe_uid));
let safe_action = alarm.action.replace(['\n', '\r'], "");
buffer.push_str(&format!("ACTION:{}\r\n", safe_action));
if let Some(desc) = &alarm.description {
let safe_desc = desc
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "");
buffer.push_str(&format!("DESCRIPTION:{}\r\n", safe_desc));
} else {
buffer.push_str("DESCRIPTION:Reminder\r\n");
}
match alarm.trigger {
AlarmTrigger::Relative(mins) => {
let sign = if mins < 0 { "-" } else { "" };
buffer.push_str(&format!("TRIGGER:{}PT{}M\r\n", sign, mins.abs()));
}
AlarmTrigger::Absolute(dt) => {
buffer.push_str(&format!(
"TRIGGER;VALUE=DATE-TIME:{}\r\n",
dt.format("%Y%m%dT%H%M%SZ")
));
}
}
if let Some(ack) = alarm.acknowledged {
let ack_str: String = ack.format("%Y%m%dT%H%M%SZ").to_string();
buffer.push_str(&format!("ACKNOWLEDGED:{}\r\n", ack_str));
}
if let Some(rel) = &alarm.related_to_uid {
let safe_rel = rel.replace(['\n', '\r'], "");
if let Some(rtype) = &alarm.relation_type {
let safe_rtype = rtype.replace(['\n', '\r'], "");
buffer.push_str(&format!(
"RELATED-TO;RELTYPE={}:{}\r\n",
safe_rtype, safe_rel
));
} else {
buffer.push_str(&format!("RELATED-TO:{}\r\n", safe_rel));
}
}
buffer.push_str("END:VALARM\r\n");
}
buffer.push_str(end);
ics = buffer;
}
if !task.raw_components.is_empty() {
let extra_len: usize = task
.raw_components
.iter()
.map(|s: &String| s.len() + 2)
.sum();
if let Some(idx) = ics.rfind("END:VCALENDAR") {
let (start, end) = ics.split_at(idx);
let mut buffer = String::with_capacity(ics.len() + extra_len);
buffer.push_str(start);
for raw in &task.raw_components {
buffer.push_str(raw);
if !raw.ends_with('\n') {
buffer.push_str("\r\n");
}
}
buffer.push_str(end);
ics = buffer;
}
}
ics
}
pub fn from_ics(
raw_ics: &str,
etag: String,
href: String,
calendar_href: String,
) -> Result<Task, String> {
let calendar: Calendar = raw_ics.parse().map_err(|e| format!("Parse: {}", e))?;
let mut master_todo: Option<&Todo> = None;
let mut raw_components: Vec<String> = Vec::new();
for component in &calendar.components {
match component {
CalendarComponent::Todo(t) => {
if t.properties().contains_key("RECURRENCE-ID") {
raw_components.push(t.to_string());
} else if master_todo.is_none() {
master_todo = Some(t);
} else {
raw_components.push(t.to_string());
}
}
CalendarComponent::Event(e) => raw_components.push(e.to_string()),
CalendarComponent::Venue(v) => raw_components.push(v.to_string()),
CalendarComponent::Other(o) => raw_components.push(o.to_string()),
_ => {}
}
}
let todo = master_todo.ok_or("No Master VTODO found in ICS".to_string())?;
let get_prop = |key: &str| -> Option<String> {
todo.properties().get(key).map(|p| p.value().to_string())
};
let uid = get_prop("UID").unwrap_or_default();
let summary = get_prop("SUMMARY").unwrap_or_default();
let description = get_prop("DESCRIPTION").unwrap_or_default();
let status = if let Some(val) = get_prop("STATUS") {
match val.trim().to_uppercase().as_str() {
"COMPLETED" => TaskStatus::Completed,
"IN-PROCESS" => TaskStatus::InProcess,
"CANCELLED" => TaskStatus::Cancelled,
_ => TaskStatus::NeedsAction,
}
} else {
TaskStatus::NeedsAction
};
let priority = get_prop("PRIORITY")
.and_then(|v| v.parse::<u8>().ok())
.map(|p| if p > 9 { 9 } else { p })
.unwrap_or(0);
let sequence = get_prop("SEQUENCE")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(0);
let percent_complete = get_prop("PERCENT-COMPLETE").and_then(|v| v.parse::<u8>().ok());
let location = get_prop("LOCATION");
let url = get_prop("URL");
let geo = get_prop("GEO").map(|s| s.replace(';', ","));
let create_event =
get_prop("X-CFAIT-CREATE-EVENT").and_then(|v| match v.trim().to_uppercase().as_str() {
"TRUE" | "1" | "YES" => Some(true),
"FALSE" | "0" | "NO" => Some(false),
_ => None,
});
let collapsed = get_prop("X-CFAIT-COLLAPSED")
.map(|v| v.trim().to_uppercase() == "TRUE")
.unwrap_or(false);
let fuzzy_due = get_prop("X-CFAIT-FUZZY-DUE");
let fuzzy_start = get_prop("X-CFAIT-FUZZY-START");
let parse_date_type =
|prop: &icalendar::Property, fuzzy: Option<String>| -> Option<DateType> {
let val = prop.value();
let standard_date = if val.len() >= 8 {
NaiveDate::parse_from_str(&val[0..8], "%Y%m%d").ok()
} else {
None
};
if let Some(f) = fuzzy
&& let Some(sd) = standard_date
{
if f.len() == 7 && f == sd.format("%Y-%m").to_string() {
return Some(DateType::Month(sd.year(), sd.month()));
} else if f.len() == 4 && f == sd.format("%Y").to_string() {
return Some(DateType::Year(sd.year()));
}
}
let is_date_param = prop
.params()
.get("VALUE")
.map(|v| v.value() == "DATE")
.unwrap_or(false);
if is_date_param || val.len() == 8 {
NaiveDate::parse_from_str(val, "%Y%m%d")
.ok()
.map(DateType::AllDay)
} else if val.ends_with('Z') {
NaiveDateTime::parse_from_str(val, "%Y%m%dT%H%M%SZ")
.ok()
.map(|d| DateType::Specific(Utc.from_utc_datetime(&d)))
} else {
NaiveDateTime::parse_from_str(val, "%Y%m%dT%H%M%S")
.ok()
.map(|d| {
DateType::Specific(crate::model::item::safe_local_to_utc(
d.date(),
d.time(),
))
})
}
};
let due = todo
.properties()
.get("DUE")
.and_then(|p| parse_date_type(p, fuzzy_due));
let dtstart = todo
.properties()
.get("DTSTART")
.and_then(|p| parse_date_type(p, fuzzy_start));
let rrule = get_prop("RRULE");
let mut exdates = Vec::new();
if let Some(multi_props) = todo.multi_properties().get("EXDATE") {
for prop in multi_props {
let is_date = prop
.params()
.get("VALUE")
.map(|v| v.value() == "DATE")
.unwrap_or(false);
let val_str = prop.value();
for part in val_str.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
if is_date || part.len() == 8 {
if let Ok(d) = NaiveDate::parse_from_str(part, "%Y%m%d") {
exdates.push(DateType::AllDay(d));
}
} else if part.ends_with('Z') {
if let Ok(dt) = NaiveDateTime::parse_from_str(part, "%Y%m%dT%H%M%SZ") {
exdates.push(DateType::Specific(Utc.from_utc_datetime(&dt)));
}
} else if let Ok(dt) = NaiveDateTime::parse_from_str(part, "%Y%m%dT%H%M%S") {
exdates.push(DateType::Specific(crate::model::item::safe_local_to_utc(
dt.date(),
dt.time(),
)));
}
}
}
}
let parse_ics_duration = |val: &str| -> i32 {
let mut minutes: i32 = 0;
let mut num_buf = String::new();
let mut in_time = false;
for c in val.chars() {
match c {
'P' | 'p' => {}
'T' | 't' => {
in_time = true;
}
d if d.is_ascii_digit() => {
num_buf.push(d);
}
unit if !num_buf.is_empty() => {
if let Ok(n) = num_buf.parse::<i32>() {
match unit {
'W' => minutes += n * 7 * 24 * 60,
'D' => minutes += n * 24 * 60,
'H' if in_time => minutes += n * 60,
'M' if in_time => minutes += n,
_ => {}
}
}
num_buf.clear();
}
_ => {}
}
}
minutes
};
let parse_dur = |val: &str| -> Option<u32> {
let mut minutes = 0u32;
let mut num_buf = String::new();
let mut in_time = false;
for c in val.chars() {
if c == 'T' {
in_time = true;
} else if c.is_numeric() {
num_buf.push(c);
} else if !num_buf.is_empty() {
let n = num_buf.parse::<u32>().unwrap_or(0);
match c {
'D' => minutes += n * 24 * 60,
'H' if in_time => minutes += n * 60,
'M' if in_time => minutes += n,
'W' => minutes += n * 7 * 24 * 60,
_ => {}
}
num_buf.clear();
}
}
if minutes > 0 { Some(minutes) } else { None }
};
let mut estimated_duration = todo
.properties()
.get("X-ESTIMATED-DURATION")
.and_then(|p: &icalendar::Property| parse_dur(p.value()));
if estimated_duration.is_none() {
estimated_duration = todo
.properties()
.get("DURATION")
.and_then(|p: &icalendar::Property| parse_dur(p.value()));
}
let estimated_duration_max = todo
.properties()
.get("X-CFAIT-ESTIMATED-DURATION-MAX")
.and_then(|p: &icalendar::Property| parse_dur(p.value()));
let mut categories = Vec::new();
if let Some(multi_props) = todo.multi_properties().get("CATEGORIES") {
for prop in multi_props {
let parts: Vec<String> = prop
.value()
.split(',')
.map(|s: &str| s.trim().to_string())
.filter(|s: &String| !s.is_empty())
.collect();
categories.extend(parts);
}
}
if let Some(prop) = todo.properties().get("CATEGORIES") {
let parts: Vec<String> = prop
.value()
.split(',')
.map(|s: &str| s.trim().to_string())
.filter(|s: &String| !s.is_empty())
.collect();
categories.extend(parts);
}
categories.sort();
categories.dedup();
let mut parent_uid = None;
let mut dependencies = Vec::new();
let mut related_to = Vec::new();
let mut manual_sessions = Vec::new();
let unfolded = icalendar::parser::unfold(raw_ics);
let mut in_vtodo = false;
let mut in_valarm = false;
for line in unfolded.lines() {
let line = line.trim();
let line_upper = line.to_uppercase();
if line_upper == "BEGIN:VTODO" {
in_vtodo = true;
continue;
}
if line_upper == "END:VTODO" {
in_vtodo = false;
continue;
}
if line_upper == "BEGIN:VALARM" {
in_valarm = true;
continue;
}
if line_upper == "END:VALARM" {
in_valarm = false;
continue;
}
if in_vtodo && !in_valarm {
if line_upper.starts_with("RELATED-TO")
&& let Some((raw_key, val)) = line.split_once(':')
{
let parts: Vec<&str> = raw_key.split(';').collect();
let mut is_dep = false;
let mut is_sibling = false;
for param in parts.iter().skip(1) {
let param_u = param.to_uppercase();
if param_u.contains("RELTYPE") {
if param_u.contains("DEPENDS-ON") {
is_dep = true;
} else if param_u.contains("SIBLING") {
is_sibling = true;
}
}
}
let value = val.trim().to_string();
if is_dep {
if !dependencies.contains(&value) {
dependencies.push(value);
}
} else if is_sibling {
if !related_to.contains(&value) {
related_to.push(value);
}
} else {
if parent_uid.is_none() {
parent_uid = Some(value);
} else if !related_to.contains(&value) {
related_to.push(value);
}
}
}
if line_upper.starts_with("X-CFAIT-SESSION:")
&& let Some((_, val)) = line.split_once(':')
{
let clean_val = val.replace("\\,", ",");
let parts = if clean_val.contains('/') {
clean_val.split_once('/')
} else {
clean_val.split_once(',')
};
if let Some((s_str, e_str)) = parts
&& let (Ok(start), Ok(end)) =
(s_str.trim().parse::<i64>(), e_str.trim().parse::<i64>())
{
manual_sessions.push(crate::model::item::WorkSession { start, end });
}
}
}
}
let mut unmapped_properties = Vec::new();
let to_raw = |prop: &icalendar::Property| -> RawProperty {
let mut params = Vec::new();
for (k, param) in prop.params().iter() {
params.push((k.clone(), param.value().to_string()));
}
if !params.is_empty() {
params.sort_unstable();
}
RawProperty {
key: prop.key().to_string(),
value: prop.value().to_string(),
params,
}
};
for (key, prop) in todo.properties() {
if !HANDLED_KEYS.contains(&key.to_uppercase().as_str()) {
unmapped_properties.push(to_raw(prop));
}
}
for (key, props) in todo.multi_properties() {
if !HANDLED_KEYS.contains(&key.to_uppercase().as_str()) {
for prop in props {
unmapped_properties.push(to_raw(prop));
}
}
}
if !unmapped_properties.is_empty() {
unmapped_properties
.sort_unstable_by(|a, b| a.key.cmp(&b.key).then(a.value.cmp(&b.value)));
}
let mut alarms = Vec::new();
let mut in_alarm = false;
let mut current_alarm_lines: Vec<String> = Vec::new();
for line in raw_ics.lines() {
let trim = line.trim();
if trim == "BEGIN:VALARM" {
in_alarm = true;
continue;
}
if trim == "END:VALARM" {
in_alarm = false;
let mut alarm = Alarm {
uid: Uuid::new_v4().to_string(),
action: "DISPLAY".to_string(),
trigger: AlarmTrigger::Relative(0),
description: None,
acknowledged: None,
related_to_uid: None,
relation_type: None,
};
for l in ¤t_alarm_lines {
if let Some((key, val)) = l.split_once(':') {
let k_upper = key.split(';').next().unwrap_or(key).to_uppercase();
match k_upper.as_str() {
"UID" => alarm.uid = val.trim().to_string(),
"ACTION" => alarm.action = val.trim().to_string(),
"DESCRIPTION" => alarm.description = Some(val.trim().to_string()),
"TRIGGER" => {
if val.contains('T') && !val.contains('P') {
if let Ok(dt) =
NaiveDateTime::parse_from_str(val.trim(), "%Y%m%dT%H%M%SZ")
{
alarm.trigger =
AlarmTrigger::Absolute(Utc.from_utc_datetime(&dt));
}
} else {
let v_trim = val.trim();
let is_neg = v_trim.starts_with('-');
let abs_mins = parse_ics_duration(if is_neg {
&v_trim[1..]
} else {
v_trim
});
alarm.trigger = AlarmTrigger::Relative(if is_neg {
-abs_mins
} else {
abs_mins
});
}
}
"ACKNOWLEDGED" => {
if let Ok(dt) =
NaiveDateTime::parse_from_str(val.trim(), "%Y%m%dT%H%M%SZ")
{
alarm.acknowledged = Some(Utc.from_utc_datetime(&dt));
}
}
"RELATED-TO" => {
alarm.related_to_uid = Some(val.trim().to_string());
if key.contains("RELTYPE=SNOOZE") {
alarm.relation_type = Some("SNOOZE".to_string());
}
}
_ => {}
}
}
}
alarms.push(alarm);
current_alarm_lines.clear();
continue;
}
if in_alarm {
current_alarm_lines.push(line.to_string());
}
}
let time_spent_seconds = get_prop("X-TIME-SPENT")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
let last_started_at = get_prop("X-LAST-START").and_then(|v| v.parse::<i64>().ok());
manual_sessions.sort_by_key(|s| s.start);
manual_sessions.dedup();
Ok(Task {
uid,
summary,
description,
status,
estimated_duration,
estimated_duration_max,
due,
dtstart,
alarms,
exdates,
priority,
percent_complete,
parent_uid,
dependencies,
related_to,
etag,
href,
calendar_href,
categories,
depth: 0,
rrule,
location,
url,
geo,
collapsed,
time_spent_seconds,
last_started_at,
sessions: manual_sessions, unmapped_properties,
sequence,
raw_alarms: Vec::new(),
raw_components,
create_event,
is_blocked: false,
is_implicitly_blocked: false,
is_implicitly_future: false,
has_subtasks: false,
has_visible_subtasks: false,
sort_rank: 0,
effective_priority: 0,
effective_due: None,
effective_dtstart: None,
visible_categories: Vec::new(),
visible_location: None,
has_blocking_tasks: false,
has_related_tasks: false,
is_future_start: false,
is_overdue: false,
})
}
pub fn to_event_ics(task: &Task) -> Vec<(String, String)> {
let mut results = Vec::new();
let base_uid = format!("evt-{}", task.uid);
let has_sessions = !task.sessions.is_empty();
for (index, session) in task.sessions.iter().enumerate() {
let start_utc =
chrono::DateTime::from_timestamp(session.start, 0).unwrap_or_else(Utc::now);
let end_utc = chrono::DateTime::from_timestamp(session.end, 0).unwrap_or_else(Utc::now);
if end_utc <= start_utc {
continue;
}
let suffix = format!("-session-{}", index);
let uid = format!("{}{}", base_uid, suffix);
let mut session_event = Event::new();
session_event.add_property("UID", &uid);
session_event.summary(&format!("⚙ {}", task.summary));
session_event.timestamp(Utc::now());
session_event.add_property("STATUS", "CONFIRMED");
session_event.add_property("SEQUENCE", task.sequence.to_string());
let duration_mins = (end_utc - start_utc).num_minutes();
let desc = format!(
"Work session recorded by Cfait.\nDuration: {}m",
duration_mins
);
session_event.description(&desc);
session_event.add_property("DTSTART", start_utc.format("%Y%m%dT%H%M%SZ").to_string());
session_event.add_property("DTEND", end_utc.format("%Y%m%dT%H%M%SZ").to_string());
let mut calendar = Calendar::new();
calendar.push(session_event);
results.push((suffix, calendar.to_string()));
}
if task.status == TaskStatus::Completed && !has_sessions {
let explicit_completion = task.completion_date().or_else(|| {
task.unmapped_properties
.iter()
.find(|p| p.key == "LAST-MODIFIED")
.and_then(|p| {
NaiveDateTime::parse_from_str(&p.value, "%Y%m%dT%H%M%SZ")
.ok()
.map(|ndt| Utc.from_utc_datetime(&ndt))
})
});
if let Some(completed_at) = explicit_completion {
let duration_mins = if task.time_spent_seconds > 0 && !has_sessions {
(task.time_spent_seconds / 60).max(1) as i64
} else {
task.estimated_duration.unwrap_or(60) as i64
};
let start_at = completed_at - chrono::Duration::minutes(duration_mins);
let ics = Self::make_single_event_ics(
task,
"",
DateType::Specific(start_at),
DateType::Specific(completed_at),
);
results.push(("".to_string(), ics));
} else if let Some(due) = &task.due {
let start_dt = match due {
DateType::AllDay(d) => DateType::AllDay(*d),
DateType::Specific(dt) => {
let duration_mins = if task.time_spent_seconds > 0 && !has_sessions {
(task.time_spent_seconds / 60).max(1) as i64
} else {
task.estimated_duration.unwrap_or(60) as i64
};
DateType::Specific(*dt - chrono::Duration::minutes(duration_mins))
}
DateType::Month(y, m) => DateType::Month(*y, *m),
DateType::Year(y) => DateType::Year(*y),
};
let ics = Self::make_single_event_ics(task, "", start_dt, due.clone());
results.push(("".to_string(), ics));
} else if let Some(start) = &task.dtstart {
let end_dt = match start {
DateType::AllDay(d) => DateType::AllDay(*d),
DateType::Specific(dt) => {
let duration_mins = if task.time_spent_seconds > 0 && !has_sessions {
(task.time_spent_seconds / 60).max(1) as i64
} else {
task.estimated_duration.unwrap_or(60) as i64
};
DateType::Specific(*dt + chrono::Duration::minutes(duration_mins))
}
DateType::Month(y, m) => DateType::Month(*y, *m),
DateType::Year(y) => DateType::Year(*y),
};
let ics = Self::make_single_event_ics(task, "", start.clone(), end_dt);
results.push(("".to_string(), ics));
}
} else if (task.due.is_some() || task.dtstart.is_some())
&& task.status != TaskStatus::Completed
{
let has_explicit_duration =
task.estimated_duration.is_some() || (task.time_spent_seconds > 0 && !has_sessions);
let duration_mins = if task.time_spent_seconds > 0 && !has_sessions {
(task.time_spent_seconds / 60).max(1) as i64
} else {
task.estimated_duration.unwrap_or(60) as i64
};
match (&task.dtstart, &task.due) {
(Some(s), Some(d)) => {
if has_explicit_duration {
match d {
DateType::AllDay(date) => {
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::AllDay(*date),
DateType::AllDay(*date),
),
));
}
DateType::Specific(dt) => {
let start_val = *dt - chrono::Duration::minutes(duration_mins);
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::Specific(start_val),
DateType::Specific(*dt),
),
));
}
DateType::Month(y, m) => {
let _date = NaiveDate::from_ymd_opt(*y, *m, 1).unwrap();
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::Month(*y, *m),
DateType::Month(*y, *m),
),
));
}
DateType::Year(y) => {
let _date = NaiveDate::from_ymd_opt(*y, 1, 1).unwrap();
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::Year(*y),
DateType::Year(*y),
),
));
}
}
} else {
let s_date = s.to_date_naive();
let d_date = d.to_date_naive();
let diff = d_date - s_date;
if diff.num_days() <= 1 {
let (final_start, final_end) = match (&s, &d) {
(DateType::AllDay(_), DateType::Specific(dt)) => (
DateType::Specific(s.to_start_comparison_time()),
DateType::Specific(*dt),
),
(DateType::Specific(dt), DateType::AllDay(_)) => (
DateType::Specific(*dt),
DateType::Specific(d.to_comparison_time()),
),
_ => (s.clone(), d.clone()),
};
results.push((
"".to_string(),
Self::make_single_event_ics(task, "", final_start, final_end),
));
} else {
let start_event_end = match s {
DateType::AllDay(date) => DateType::AllDay(*date),
DateType::Specific(dt) => {
DateType::Specific(*dt + chrono::Duration::hours(1))
}
DateType::Month(y, m) => DateType::Month(*y, *m),
DateType::Year(y) => DateType::Year(*y),
};
let due_event_start = match d {
DateType::AllDay(date) => DateType::AllDay(*date),
DateType::Specific(dt) => {
DateType::Specific(*dt - chrono::Duration::hours(1))
}
DateType::Month(y, m) => DateType::Month(*y, *m),
DateType::Year(y) => DateType::Year(*y),
};
results.push((
"-start".to_string(),
Self::make_single_event_ics(
task,
"-start",
s.clone(),
start_event_end,
),
));
results.push((
"-due".to_string(),
Self::make_single_event_ics(
task,
"-due",
due_event_start,
d.clone(),
),
));
}
}
}
(Some(s), None) => {
match s {
DateType::AllDay(d) => {
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::AllDay(*d),
DateType::AllDay(*d),
),
));
}
DateType::Specific(dt) => {
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::Specific(*dt),
DateType::Specific(
*dt + chrono::Duration::minutes(duration_mins),
),
),
));
}
DateType::Month(y, m) => {
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::Month(*y, *m),
DateType::Month(*y, *m),
),
));
}
DateType::Year(y) => {
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::Year(*y),
DateType::Year(*y),
),
));
}
}
}
(None, Some(d)) => {
match d {
DateType::AllDay(d) => {
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::AllDay(*d),
DateType::AllDay(*d),
),
));
}
DateType::Specific(dt) => {
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::Specific(
*dt - chrono::Duration::minutes(duration_mins),
),
DateType::Specific(*dt),
),
));
}
DateType::Month(y, m) => {
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::Month(*y, *m),
DateType::Month(*y, *m),
),
));
}
DateType::Year(y) => {
results.push((
"".to_string(),
Self::make_single_event_ics(
task,
"",
DateType::Year(*y),
DateType::Year(*y),
),
));
}
}
}
(None, None) => {}
}
}
results
}
fn make_single_event_ics(
task: &Task,
suffix: &str,
start_dt: DateType,
end_dt: DateType,
) -> String {
let base_uid = format!("evt-{}", task.uid);
let uid = format!("{}{}", base_uid, suffix);
let mut event = Event::new();
event.add_property("UID", &uid);
event.add_property("X-CFAIT-TASK-UID", &task.uid);
let mut summary = match suffix {
"-start" => format!("{} (start)", task.summary),
"-due" => format!("{} (due)", task.summary),
_ => task.summary.clone(),
};
if task.status == crate::model::TaskStatus::Completed {
summary = format!("🗹 {}", summary);
}
event.summary(&summary);
event.timestamp(Utc::now());
event.add_property("SEQUENCE", task.sequence.to_string());
let mut event_desc = String::new();
if !task.description.is_empty() {
event_desc.push_str(&task.description);
event_desc.push_str("\n\n");
}
event_desc.push_str(&format!("⚠️ {}\n", rust_i18n::t!("ics_event_auto_created")));
if task.status == TaskStatus::Completed {
event_desc.push_str(&format!(
"✓ {}\n",
rust_i18n::t!("ics_event_task_completed")
));
} else {
event_desc.push_str(&format!("{}\n", rust_i18n::t!("ics_event_auto_updated")));
event_desc.push_str(&format!("{}\n", rust_i18n::t!("ics_event_changes_lost")));
}
event.description(&event_desc);
if let Some(loc) = &task.location {
event.add_property("LOCATION", loc);
}
if let Some(url) = &task.url {
event.add_property("URL", url);
}
let is_time = matches!(start_dt, DateType::Specific(_));
let default_utc_time = match &start_dt {
DateType::Specific(dt) => dt.time(),
_ => chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
};
if let Some(rrule) = &task.rrule {
if task.status != TaskStatus::Completed {
let fixed_rrule = Self::fix_rrule_until(rrule, is_time);
event.add_property("RRULE", &fixed_rrule);
for ex in &task.exdates {
let ex_is_time = matches!(ex, DateType::Specific(_));
if is_time && !ex_is_time {
let date = ex.to_date_naive();
let dt = Utc.from_utc_datetime(&date.and_time(default_utc_time));
event.append_multi_property(icalendar::Property::new(
"EXDATE",
dt.format("%Y%m%dT%H%M%SZ").to_string(),
));
} else if !is_time && ex_is_time {
let mut p = icalendar::Property::new(
"EXDATE",
ex.to_date_naive().format("%Y%m%d").to_string(),
);
p.add_parameter("VALUE", "DATE");
event.append_multi_property(p);
} else {
match ex {
DateType::AllDay(d) => {
let mut p = icalendar::Property::new(
"EXDATE",
d.format("%Y%m%d").to_string(),
);
p.add_parameter("VALUE", "DATE");
event.append_multi_property(p);
}
DateType::Specific(dt) => {
event.append_multi_property(icalendar::Property::new(
"EXDATE",
dt.format("%Y%m%dT%H%M%SZ").to_string(),
));
}
DateType::Month(y, m) => {
let d = NaiveDate::from_ymd_opt(*y, *m, 1).unwrap();
let mut p = icalendar::Property::new(
"EXDATE",
d.format("%Y%m%d").to_string(),
);
p.add_parameter("VALUE", "DATE");
event.append_multi_property(p);
}
DateType::Year(y) => {
let d = NaiveDate::from_ymd_opt(*y, 1, 1).unwrap();
let mut p = icalendar::Property::new(
"EXDATE",
d.format("%Y%m%d").to_string(),
);
p.add_parameter("VALUE", "DATE");
event.append_multi_property(p);
}
}
}
}
}
}
match task.status {
TaskStatus::Cancelled => {
event.add_property("STATUS", "CANCELLED");
}
_ => {
event.add_property("STATUS", "CONFIRMED");
}
}
match start_dt {
DateType::AllDay(d) => {
let mut p = icalendar::Property::new("DTSTART", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
event.append_property(p);
}
DateType::Specific(t) => {
event.add_property("DTSTART", t.format("%Y%m%dT%H%M%SZ").to_string());
}
DateType::Month(y, m) => {
let d = NaiveDate::from_ymd_opt(y, m, 1).unwrap();
let mut p = icalendar::Property::new("DTSTART", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
event.append_property(p);
}
DateType::Year(y) => {
let d = NaiveDate::from_ymd_opt(y, 1, 1).unwrap();
let mut p = icalendar::Property::new("DTSTART", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
event.append_property(p);
}
}
match end_dt {
DateType::AllDay(d) => {
let next_day = d + chrono::Duration::days(1);
let mut p =
icalendar::Property::new("DTEND", next_day.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
event.append_property(p);
}
DateType::Specific(t) => {
event.add_property("DTEND", t.format("%Y%m%dT%H%M%SZ").to_string());
}
DateType::Month(y, m) => {
let next_m = if m == 12 { 1 } else { m + 1 };
let next_y = if m == 12 { y + 1 } else { y };
let d = NaiveDate::from_ymd_opt(next_y, next_m, 1).unwrap();
let mut p = icalendar::Property::new("DTEND", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
event.append_property(p);
}
DateType::Year(y) => {
let d = NaiveDate::from_ymd_opt(y + 1, 1, 1).unwrap();
let mut p = icalendar::Property::new("DTEND", d.format("%Y%m%d").to_string());
p.add_parameter("VALUE", "DATE");
event.append_property(p);
}
}
let mut calendar = Calendar::new();
calendar.push(event);
calendar.to_string()
}
}