use chrono::{DateTime, Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
fn default_uid() -> String {
Uuid::new_v4().to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarListEntry {
pub name: String,
pub href: String,
pub color: Option<String>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
pub enum TaskStatus {
NeedsAction,
InProcess,
Completed,
Cancelled,
}
impl TaskStatus {
pub fn is_done(&self) -> bool {
matches!(self, Self::Completed | Self::Cancelled)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct RawProperty {
pub key: String,
pub value: String,
pub params: Vec<(String, String)>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct WorkSession {
pub start: i64,
pub end: i64,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum DateType {
AllDay(NaiveDate),
Specific(DateTime<Utc>),
Month(i32, u32), Year(i32), }
pub fn safe_local_to_utc(date: NaiveDate, time: NaiveTime) -> DateTime<Utc> {
let ndt = date.and_time(time);
match ndt.and_local_timezone(Local) {
chrono::LocalResult::Single(dt) => dt.with_timezone(&Utc),
chrono::LocalResult::Ambiguous(dt1, _) => dt1.with_timezone(&Utc), chrono::LocalResult::None => {
let shifted = ndt + chrono::Duration::hours(1);
match shifted.and_local_timezone(Local) {
chrono::LocalResult::Single(dt2) | chrono::LocalResult::Ambiguous(dt2, _) => {
dt2.with_timezone(&Utc) - chrono::Duration::hours(1)
}
chrono::LocalResult::None => {
chrono::TimeZone::from_utc_datetime(&Utc, &ndt)
}
}
}
}
}
impl DateType {
pub fn to_date_naive(&self) -> NaiveDate {
match self {
DateType::AllDay(d) => *d,
DateType::Specific(dt) => dt.with_timezone(&Local).date_naive(),
DateType::Month(y, m) => NaiveDate::from_ymd_opt(*y, *m, 1).unwrap(),
DateType::Year(y) => NaiveDate::from_ymd_opt(*y, 1, 1).unwrap(),
}
}
pub fn to_comparison_time(&self) -> DateTime<Utc> {
match self {
DateType::AllDay(d) => {
safe_local_to_utc(*d, chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap())
}
DateType::Specific(dt) => *dt,
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();
safe_local_to_utc(d, chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap())
}
DateType::Year(y) => {
let d = NaiveDate::from_ymd_opt(*y, 12, 31).unwrap();
safe_local_to_utc(d, chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap())
}
}
}
pub fn to_start_comparison_time(&self) -> DateTime<Utc> {
match self {
DateType::AllDay(d) => {
safe_local_to_utc(*d, chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap())
}
DateType::Specific(dt) => *dt,
DateType::Month(y, m) => {
let d = NaiveDate::from_ymd_opt(*y, *m, 1).unwrap();
safe_local_to_utc(d, chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap())
}
DateType::Year(y) => {
let d = NaiveDate::from_ymd_opt(*y, 1, 1).unwrap();
safe_local_to_utc(d, chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap())
}
}
}
pub fn format_smart(&self) -> String {
use chrono::Timelike;
match self {
DateType::AllDay(d) => d.format("%Y-%m-%d").to_string(),
DateType::Specific(dt) => {
let local = dt.with_timezone(&Local);
if local.hour() == 0 && local.minute() == 0 && local.second() == 0 {
local.format("%Y-%m-%d").to_string()
} else {
local.format("%Y-%m-%d %H:%M").to_string()
}
}
DateType::Month(y, m) => format!("{:04}-{:02}", y, m),
DateType::Year(y) => format!("{:04}", y),
}
}
pub fn to_utc_with_default_time(&self, default_time: NaiveTime) -> DateTime<Utc> {
match self {
DateType::Specific(dt) => *dt,
DateType::AllDay(d) => safe_local_to_utc(*d, default_time),
DateType::Month(y, m) => {
safe_local_to_utc(NaiveDate::from_ymd_opt(*y, *m, 1).unwrap(), default_time)
}
DateType::Year(y) => {
safe_local_to_utc(NaiveDate::from_ymd_opt(*y, 1, 1).unwrap(), default_time)
}
}
}
}
impl Ord for DateType {
fn cmp(&self, other: &Self) -> Ordering {
self.to_comparison_time().cmp(&other.to_comparison_time())
}
}
impl PartialOrd for DateType {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum AlarmTrigger {
Relative(i32), Absolute(DateTime<Utc>), }
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct Alarm {
#[serde(default = "default_uid")]
pub uid: String,
pub action: String,
pub trigger: AlarmTrigger,
pub description: Option<String>,
pub acknowledged: Option<DateTime<Utc>>,
pub related_to_uid: Option<String>,
pub relation_type: Option<String>,
}
impl Alarm {
pub fn new_relative(minutes_before: u32) -> Self {
Self {
uid: default_uid(),
action: "DISPLAY".to_string(),
trigger: AlarmTrigger::Relative(-(minutes_before as i32)),
description: None,
acknowledged: None,
related_to_uid: None,
relation_type: None,
}
}
pub fn new_absolute(dt: DateTime<Utc>) -> Self {
Self {
uid: default_uid(),
action: "DISPLAY".to_string(),
trigger: AlarmTrigger::Absolute(dt),
description: None,
acknowledged: None,
related_to_uid: None,
relation_type: None,
}
}
pub fn is_snooze(&self) -> bool {
self.relation_type.as_deref() == Some("SNOOZE")
}
}
fn deserialize_date_option<'de, D>(deserializer: D) -> Result<Option<DateType>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum DateTypeOrLegacy {
New(DateType),
Legacy(DateTime<Utc>),
}
let v: Option<DateTypeOrLegacy> = Option::deserialize(deserializer)?;
match v {
Some(DateTypeOrLegacy::New(d)) => Ok(Some(d)),
Some(DateTypeOrLegacy::Legacy(d)) => {
let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
if d.time() == midnight {
Ok(Some(DateType::AllDay(d.date_naive())))
} else {
Ok(Some(DateType::Specific(d)))
}
}
None => Ok(None),
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct Task {
pub uid: String,
pub summary: String,
pub description: String,
pub status: TaskStatus,
pub estimated_duration: Option<u32>,
#[serde(default)]
pub estimated_duration_max: Option<u32>,
#[serde(default, deserialize_with = "deserialize_date_option")]
pub due: Option<DateType>,
#[serde(default, deserialize_with = "deserialize_date_option")]
pub dtstart: Option<DateType>,
#[serde(default)]
pub alarms: Vec<Alarm>,
#[serde(default)]
pub exdates: Vec<DateType>,
pub priority: u8,
pub percent_complete: Option<u8>,
pub parent_uid: Option<String>,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default)]
pub related_to: Vec<String>,
pub etag: String,
pub href: String,
pub calendar_href: String,
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub depth: usize,
pub rrule: Option<String>,
pub location: Option<String>,
pub url: Option<String>,
pub geo: Option<String>,
#[serde(default)]
pub collapsed: bool,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub time_spent_seconds: u64,
#[serde(default)]
pub last_started_at: Option<i64>,
#[serde(default)]
pub sessions: Vec<WorkSession>,
#[serde(default)]
pub unmapped_properties: Vec<RawProperty>,
#[serde(default)]
pub sequence: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub raw_alarms: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub raw_components: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub create_event: Option<bool>,
#[serde(skip)]
pub is_blocked: bool,
#[serde(skip)]
pub is_implicitly_blocked: bool,
#[serde(skip)]
pub is_implicitly_future: bool,
#[serde(skip)]
pub has_subtasks: bool,
#[serde(skip)]
pub has_visible_subtasks: bool,
#[serde(skip)]
pub sort_rank: u8,
#[serde(skip)]
pub effective_priority: u8,
#[serde(skip)]
pub effective_due: Option<DateType>,
#[serde(skip)]
pub effective_dtstart: Option<DateType>,
#[serde(skip)]
pub visible_categories: Vec<String>,
#[serde(skip)]
pub visible_location: Option<String>,
#[serde(skip)]
pub has_blocking_tasks: bool,
#[serde(skip)]
pub has_related_tasks: bool,
#[serde(skip)]
pub is_future_start: bool,
#[serde(skip)]
pub is_overdue: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SortKey {
pub rank: u8,
pub prio: u8,
pub due: Option<DateType>,
pub start: Option<DateType>,
pub is_overdue: bool,
}
pub struct CompareOptions {
pub cutoff: Option<DateTime<Utc>>,
pub urgent_days: u32,
pub urgent_prio: u8,
pub default_priority: u8,
pub start_grace_period_days: u32,
pub sort_standard_by_priority: bool,
pub sort_preset: crate::config::SortPreset,
}
pub fn compare_sortkeys(
a: &SortKey,
b: &SortKey,
default_prio: u8,
sort_standard_by_priority: bool,
) -> Ordering {
let effective_rank = |rank: u8| {
if sort_standard_by_priority && rank == 5 {
4
} else {
rank
}
};
if effective_rank(a.rank) != effective_rank(b.rank) {
return effective_rank(a.rank).cmp(&effective_rank(b.rank));
}
let norm_prio = |p: u8| if p == 0 { default_prio } else { p };
let compare_dates = |d1: &Option<DateType>, d2: &Option<DateType>| -> Ordering {
match (d1, d2) {
(Some(a), Some(b)) => a.cmp(b),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
};
match effective_rank(a.rank) {
1 => norm_prio(a.prio)
.cmp(&norm_prio(b.prio))
.then_with(|| compare_dates(&a.due, &b.due)),
2 => {
b.is_overdue
.cmp(&a.is_overdue) .then_with(|| {
if a.is_overdue && b.is_overdue {
norm_prio(a.prio)
.cmp(&norm_prio(b.prio))
.then_with(|| compare_dates(&a.due, &b.due))
} else {
compare_dates(&a.due, &b.due)
.then(norm_prio(a.prio).cmp(&norm_prio(b.prio)))
}
})
}
3 => compare_dates(&a.due, &b.due).then(norm_prio(a.prio).cmp(&norm_prio(b.prio))),
4 => {
if sort_standard_by_priority {
norm_prio(a.prio)
.cmp(&norm_prio(b.prio))
.then_with(|| compare_dates(&a.due, &b.due))
} else {
compare_dates(&a.due, &b.due).then(norm_prio(a.prio).cmp(&norm_prio(b.prio)))
}
}
5 => norm_prio(a.prio)
.cmp(&norm_prio(b.prio))
.then_with(|| compare_dates(&a.due, &b.due)),
7 => {
let s1 = a
.start
.as_ref()
.map(|d: &DateType| d.to_start_comparison_time());
let s2 = b
.start
.as_ref()
.map(|d: &DateType| d.to_start_comparison_time());
s1.cmp(&s2).then(norm_prio(a.prio).cmp(&norm_prio(b.prio)))
}
_ => norm_prio(a.prio)
.cmp(&norm_prio(b.prio))
.then_with(|| compare_dates(&a.due, &b.due)),
}
}
impl Task {
pub fn has_extractable_subtasks(&self) -> bool {
crate::model::extractor::has_extractable_subtasks(&self.description)
}
pub fn is_relative_recurrence(&self) -> bool {
self.unmapped_properties
.iter()
.any(|p| p.key == "X-CFAIT-RECUR-FROM-COMPLETION")
}
fn parse_ics_datetime(v: &str) -> Option<DateTime<Utc>> {
if v.contains('T') {
NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%SZ")
.ok()
.map(|ndt| Utc.from_utc_datetime(&ndt))
.or_else(|| {
NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%S")
.ok()
.map(|ndt| Utc.from_utc_datetime(&ndt))
})
.or_else(|| {
DateTime::parse_from_rfc3339(v)
.ok()
.map(|dt| dt.with_timezone(&Utc))
})
} else {
None
}
}
pub fn created_date(&self) -> Option<DateTime<Utc>> {
self.unmapped_properties
.iter()
.find(|p| p.key == "CREATED")
.and_then(|p| Self::parse_ics_datetime(p.value.trim()))
}
pub fn last_modified_date(&self) -> Option<DateTime<Utc>> {
self.unmapped_properties
.iter()
.find(|p| p.key == "LAST-MODIFIED")
.or_else(|| self.unmapped_properties.iter().find(|p| p.key == "DTSTAMP"))
.and_then(|p| Self::parse_ics_datetime(p.value.trim()))
}
pub fn completion_date(&self) -> Option<DateTime<Utc>> {
self.unmapped_properties
.iter()
.find(|p| p.key == "COMPLETED")
.and_then(|p| {
let v = p.value.trim();
Self::parse_ics_datetime(v).or_else(|| {
NaiveDate::parse_from_str(v, "%Y%m%d")
.ok()
.and_then(|nd| nd.and_hms_opt(0, 0, 0))
.map(|ndt| Utc.from_utc_datetime(&ndt))
})
})
}
pub fn set_completion_date(&mut self, dt: Option<DateTime<Utc>>) {
self.unmapped_properties.retain(|p| p.key != "COMPLETED");
if let Some(date) = dt {
if !self.status.is_done() {
self.status = TaskStatus::Completed;
}
let val = date.format("%Y%m%dT%H%M%SZ").to_string();
self.unmapped_properties.push(RawProperty {
key: "COMPLETED".to_string(),
value: val,
params: vec![],
});
}
}
pub fn add_session(&mut self, session: WorkSession) {
let dur = (session.end - session.start).max(0) as u64;
self.time_spent_seconds = self.time_spent_seconds.saturating_add(dur);
self.sessions.push(session);
self.sessions.sort_by_key(|s| s.start);
}
pub fn remove_session(&mut self, idx: usize) {
if idx < self.sessions.len() {
let session = self.sessions.remove(idx);
let dur = (session.end - session.start).max(0) as u64;
self.time_spent_seconds = self.time_spent_seconds.saturating_sub(dur);
}
}
pub fn new(
input: &str,
aliases: &HashMap<String, Vec<String>>,
default_reminder_time: Option<NaiveTime>,
) -> Self {
let mut task = Self {
uid: Uuid::new_v4().to_string(),
summary: String::new(),
description: String::new(),
status: TaskStatus::NeedsAction,
estimated_duration: None,
estimated_duration_max: None,
due: None,
dtstart: None,
alarms: Vec::new(),
exdates: Vec::new(),
priority: 0,
percent_complete: None,
parent_uid: None,
dependencies: Vec::new(),
related_to: Vec::new(),
etag: String::new(),
href: String::new(),
calendar_href: String::new(),
categories: Vec::new(),
depth: 0,
rrule: None,
location: None,
url: None,
geo: None,
collapsed: false,
pinned: false,
time_spent_seconds: 0,
last_started_at: None,
sessions: Vec::new(),
unmapped_properties: Vec::new(),
sequence: 0,
raw_alarms: Vec::new(),
raw_components: Vec::new(),
create_event: None,
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,
};
task.apply_smart_input(input, aliases, default_reminder_time);
task
}
pub fn apply_smart_input(
&mut self,
input: &str,
aliases: &HashMap<String, Vec<String>>,
default_reminder_time: Option<NaiveTime>,
) {
super::parser::apply_smart_input(self, input, aliases, default_reminder_time);
}
pub fn calculate_base_rank(
&self,
cutoff: Option<DateTime<Utc>>,
urgent_days: u32,
urgent_prio: u8,
start_grace_period_days: u32,
effectively_blocked: bool,
sort_preset: crate::config::SortPreset,
) -> u8 {
if self.calendar_href == "local://trash" {
return 9;
}
if self.status.is_done() {
return 8;
}
let now = Utc::now();
if self.status != TaskStatus::InProcess {
if let Some(start) = &self.effective_dtstart {
let start_time = start.to_start_comparison_time();
let grace_threshold = now + chrono::Duration::days(start_grace_period_days as i64);
if start_time > grace_threshold && !self.has_active_or_recent_alarm() {
return 7;
}
}
if effectively_blocked {
return 6;
}
}
if self.pinned {
return 0;
}
let is_urgent = self.effective_priority > 0 && self.effective_priority <= urgent_prio;
let is_due_soon = self.effective_due.as_ref().is_some_and(|due| {
due.to_comparison_time() <= now + chrono::Duration::days(urgent_days as i64)
});
let is_in_process = self.status == TaskStatus::InProcess;
match sort_preset {
crate::config::SortPreset::UrgentStartedDue => {
if is_urgent {
return 1;
}
if is_in_process {
return 2;
}
if is_due_soon {
return 3;
}
}
crate::config::SortPreset::UrgentDueStarted => {
if is_urgent {
return 1;
}
if is_due_soon {
return 2;
}
if is_in_process {
return 3;
}
}
crate::config::SortPreset::StartedUrgentDue => {
if is_in_process {
return 1;
}
if is_urgent {
return 2;
}
if is_due_soon {
return 3;
}
}
}
if let Some(due) = &self.effective_due {
if let Some(limit) = cutoff {
if due.to_comparison_time() <= limit {
return 4;
}
} else {
return 4;
}
}
5
}
pub fn compare_for_sort(
&self,
other: &Self,
default_priority: u8,
sort_standard_by_priority: bool,
) -> Ordering {
if self.sort_rank == 9 && other.sort_rank == 9 {
return other
.completion_date()
.cmp(&self.completion_date())
.then_with(|| self.summary.cmp(&other.summary));
}
if self.sort_rank == 8 && other.sort_rank == 8 {
return other
.completion_date()
.cmp(&self.completion_date())
.then_with(|| self.summary.cmp(&other.summary));
}
let a = SortKey {
rank: self.sort_rank,
prio: self.effective_priority,
due: self.effective_due.clone(),
start: self.effective_dtstart.clone(),
is_overdue: self.is_overdue,
};
let b = SortKey {
rank: other.sort_rank,
prio: other.effective_priority,
due: other.effective_due.clone(),
start: other.effective_dtstart.clone(),
is_overdue: other.is_overdue,
};
compare_sortkeys(&a, &b, default_priority, sort_standard_by_priority)
.then_with(|| self.summary.cmp(&other.summary))
}
pub fn compare_with_cutoff(&self, other: &Self, opts: &CompareOptions) -> Ordering {
let eff_blocked_self = self.is_blocked || self.is_implicitly_blocked;
let eff_blocked_other = other.is_blocked || other.is_implicitly_blocked;
let rank_self = self.calculate_base_rank(
opts.cutoff,
opts.urgent_days,
opts.urgent_prio,
opts.start_grace_period_days,
eff_blocked_self,
opts.sort_preset,
);
let rank_other = other.calculate_base_rank(
opts.cutoff,
opts.urgent_days,
opts.urgent_prio,
opts.start_grace_period_days,
eff_blocked_other,
opts.sort_preset,
);
let a = SortKey {
rank: rank_self,
prio: self.priority,
due: self.due.clone(),
start: self.dtstart.clone(),
is_overdue: self.is_overdue,
};
let b = SortKey {
rank: rank_other,
prio: other.priority,
due: other.due.clone(),
start: other.dtstart.clone(),
is_overdue: other.is_overdue,
};
compare_sortkeys(
&a,
&b,
opts.default_priority,
opts.sort_standard_by_priority,
)
.then_with(|| self.summary.cmp(&other.summary))
}
pub fn handle_dismiss(&mut self, alarm_uid: &str) -> bool {
if alarm_uid.starts_with("implicit_") {
let parts: Vec<&str> = alarm_uid.split('|').collect();
if parts.len() >= 2
&& let Ok(dt) = chrono::DateTime::parse_from_rfc3339(parts[1])
{
let desc = if alarm_uid.contains("due") {
"Due now"
} else {
"Starting"
};
self.dismiss_implicit_alarm(dt.with_timezone(&chrono::Utc), desc.to_string());
return true;
}
return false;
}
self.dismiss_alarm(alarm_uid)
}
pub fn handle_snooze(&mut self, alarm_uid: &str, mins: u32) -> bool {
if alarm_uid.starts_with("implicit_") {
let parts: Vec<&str> = alarm_uid.split('|').collect();
if parts.len() >= 2
&& let Ok(dt) = chrono::DateTime::parse_from_rfc3339(parts[1])
{
let desc = if alarm_uid.contains("due") {
"Due now"
} else {
"Starting"
};
self.snooze_implicit_alarm(dt.with_timezone(&chrono::Utc), desc.to_string(), mins);
return true;
}
return false;
}
self.snooze_alarm(alarm_uid, mins)
}
pub fn dismiss_alarm(&mut self, alarm_uid: &str) -> bool {
if let Some(alarm) = self.alarms.iter_mut().find(|a| a.uid == alarm_uid) {
alarm.acknowledged = Some(Utc::now());
return true;
}
false
}
pub fn snooze_alarm(&mut self, alarm_uid: &str, minutes: u32) -> bool {
let now = Utc::now();
let mut new_alarm_opt = None;
if let Some(parent_alarm) = self.alarms.iter_mut().find(|a| a.uid == alarm_uid) {
parent_alarm.acknowledged = Some(now);
let trigger_time = now + chrono::Duration::minutes(minutes as i64);
let mut snooze = Alarm::new_absolute(trigger_time);
let root_uid = if parent_alarm.is_snooze() {
parent_alarm
.related_to_uid
.clone()
.unwrap_or(parent_alarm.uid.clone())
} else {
parent_alarm.uid.clone()
};
snooze.related_to_uid = Some(root_uid);
snooze.relation_type = Some("SNOOZE".to_string());
snooze.description = Some(format!("Snoozed for {}m", minutes));
snooze.action = parent_alarm.action.clone();
new_alarm_opt = Some(snooze);
}
self.alarms.retain(|a| {
if a.uid == alarm_uid && a.is_snooze() {
return false;
}
true
});
if let Some(new_alarm) = new_alarm_opt {
self.alarms.push(new_alarm);
return true;
}
false
}
pub fn next_trigger_timestamp(&self) -> Option<i64> {
let now = Utc::now();
let mut earliest: Option<i64> = None;
for alarm in &self.alarms {
if alarm.acknowledged.is_some() {
continue;
}
let trigger_dt = match alarm.trigger {
AlarmTrigger::Absolute(dt) => dt,
_ => {
let anchor = if let Some(DateType::Specific(d)) = self.due {
d
} else if let Some(DateType::Specific(s)) = self.dtstart {
s
} else {
continue;
};
anchor
+ chrono::Duration::minutes(match alarm.trigger {
AlarmTrigger::Relative(mins) => mins as i64,
_ => 0,
})
}
};
if trigger_dt > now || (now - trigger_dt).num_hours() < 24 {
let ts = trigger_dt.timestamp();
match earliest {
Some(e) if ts < e => earliest = Some(ts),
None => earliest = Some(ts),
_ => {}
}
}
}
earliest
}
pub fn has_alarm_at(&self, dt: DateTime<Utc>) -> bool {
self.alarms.iter().any(|a| match a.trigger {
AlarmTrigger::Absolute(t) => t == dt,
_ => false,
})
}
pub fn has_active_or_recent_alarm(&self) -> bool {
let now = Utc::now();
self.alarms.iter().any(|alarm| {
if let Some(ack) = alarm.acknowledged {
(now - ack).num_hours().abs() < 24
} else {
let trigger_dt = match alarm.trigger {
AlarmTrigger::Absolute(dt) => dt,
AlarmTrigger::Relative(mins) => {
let anchor = if let Some(DateType::Specific(d)) = self.due {
d
} else if let Some(DateType::Specific(s)) = self.dtstart {
s
} else {
return false;
};
anchor + chrono::Duration::minutes(mins as i64)
}
};
trigger_dt <= now && (now - trigger_dt).num_days() < 3
}
})
}
pub fn dismiss_implicit_alarm(&mut self, trigger_dt: DateTime<Utc>, description: String) {
if self.has_alarm_at(trigger_dt) {
return;
}
let mut alarm = Alarm::new_absolute(trigger_dt);
alarm.description = Some(description);
alarm.acknowledged = Some(Utc::now());
self.alarms.push(alarm);
}
pub fn snooze_implicit_alarm(
&mut self,
trigger_dt: DateTime<Utc>,
description: String,
snooze_mins: u32,
) {
let mut parent = Alarm::new_absolute(trigger_dt);
parent.description = Some(description);
parent.acknowledged = Some(Utc::now());
let parent_uid = parent.uid.clone();
self.alarms.push(parent);
let now = Utc::now();
let next_trigger = now + chrono::Duration::minutes(snooze_mins as i64);
let mut snooze = Alarm::new_absolute(next_trigger);
snooze.related_to_uid = Some(parent_uid);
snooze.relation_type = Some("SNOOZE".to_string());
snooze.description = Some(format!("Snoozed for {}m", snooze_mins));
self.alarms.push(snooze);
}
pub fn resolve_visual_attributes(
&self,
parent_tags: &HashSet<String>,
parent_location: &Option<String>,
aliases: &HashMap<String, Vec<String>>,
) -> (Vec<String>, Option<String>) {
use crate::model::parser::strip_quotes;
let mut hidden_tags = parent_tags.clone();
let mut hidden_location = parent_location.clone();
let mut process_expansions = |targets: &Vec<String>| {
for target in targets {
if let Some(val) = target.strip_prefix('#') {
hidden_tags.insert(strip_quotes(val));
} else if let Some(val) = target.strip_prefix("@@") {
hidden_location = Some(strip_quotes(val));
} else if target.to_lowercase().starts_with("loc:") {
hidden_location = Some(strip_quotes(&target[4..]));
}
}
};
for cat in &self.categories {
if let Some(val) = aliases.get(cat) {
process_expansions(val);
}
let mut search = cat.as_str();
while let Some(idx) = search.rfind(':') {
search = &search[..idx];
if let Some(targets) = aliases.get(search) {
process_expansions(targets);
}
}
}
if let Some(loc) = &self.location {
let key = format!("@@{}", loc);
if let Some(targets) = aliases.get(&key) {
process_expansions(targets);
}
let mut search = key.as_str();
while let Some(idx) = search.rfind(':') {
if idx < 2 {
break;
}
search = &search[..idx];
if let Some(targets) = aliases.get(search) {
process_expansions(targets);
}
}
}
let mut visible_tags = Vec::new();
for cat in &self.categories {
if !hidden_tags.contains(cat) {
visible_tags.push(cat.clone());
}
}
visible_tags.sort();
let visible_location = if let Some(loc) = &self.location {
if hidden_location.as_ref() != Some(loc) {
Some(loc.clone())
} else {
None
}
} else {
None
};
(visible_tags, visible_location)
}
pub fn recycle(&self, target_status: TaskStatus, shift_schedule: bool) -> (Task, Option<Task>) {
let mut base_task = self.clone();
if let Some(start_ts) = base_task.last_started_at {
let now = Utc::now().timestamp();
if now > start_ts {
base_task.time_spent_seconds = base_task
.time_spent_seconds
.saturating_add((now - start_ts) as u64);
}
base_task.last_started_at = None;
}
if base_task.status == target_status && target_status.is_done() {
let mut updated = base_task.clone();
updated.status = TaskStatus::NeedsAction;
updated.percent_complete = None;
updated.unmapped_properties.retain(|p| p.key != "COMPLETED");
return (updated, None);
}
let is_relative = shift_schedule || base_task.is_relative_recurrence();
if base_task.rrule.is_some() && target_status.is_done() {
let mut history = base_task.clone();
history.uid = Uuid::new_v4().to_string();
history.href = String::new();
history.etag = String::new();
history.status = target_status;
history.rrule = None; history.alarms.clear(); history.create_event = None;
history.unmapped_properties.push(RawProperty {
key: "X-CFAIT-HISTORY-OF".to_string(),
value: base_task.uid.clone(),
params: vec![],
});
history.related_to.push(base_task.uid.clone());
let now_str = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
history.unmapped_properties.retain(|p| p.key != "COMPLETED");
history.unmapped_properties.push(RawProperty {
key: "COMPLETED".to_string(),
value: now_str,
params: vec![],
});
if target_status == TaskStatus::Completed {
history.percent_complete = Some(100);
}
let mut next_task = base_task.clone();
if is_relative && target_status == TaskStatus::Completed {
let now = Utc::now();
let today = now.date_naive();
let update_date = |d: &mut DateType| match d {
DateType::Specific(_) => *d = DateType::Specific(now),
DateType::AllDay(_) => *d = DateType::AllDay(today),
DateType::Month(_, _) => *d = DateType::Month(today.year(), today.month()),
DateType::Year(_) => *d = DateType::Year(today.year()),
};
if let Some(ref mut d) = next_task.dtstart {
update_date(d);
}
if let Some(ref mut d) = next_task.due {
update_date(d);
}
}
if target_status == TaskStatus::Cancelled {
if let Some(date_to_exclude) = next_task.dtstart.as_ref().or(next_task.due.as_ref())
{
next_task.exdates.push(date_to_exclude.clone());
}
next_task.exdates.sort_by(|a, b| a.partial_cmp(b).unwrap());
next_task.exdates.dedup();
}
next_task.time_spent_seconds = 0;
next_task.last_started_at = None;
let advanced = crate::model::RecurrenceEngine::advance(&mut next_task);
if advanced {
return (history, Some(next_task));
}
}
let mut updated = base_task.clone();
updated.status = target_status;
if target_status.is_done() {
let now_str = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
updated.unmapped_properties.retain(|p| p.key != "COMPLETED");
updated.unmapped_properties.push(RawProperty {
key: "COMPLETED".to_string(),
value: now_str,
params: vec![],
});
if target_status == TaskStatus::Completed {
updated.percent_complete = Some(100);
}
} else {
updated.percent_complete = None;
updated.unmapped_properties.retain(|p| p.key != "COMPLETED");
}
(updated, None)
}
}
impl Task {
pub fn from_ics(
raw_ics: &str,
etag: String,
href: String,
calendar_href: String,
) -> Result<Task, String> {
crate::model::IcsAdapter::from_ics(raw_ics, etag, href, calendar_href)
}
pub fn to_ics(&self) -> String {
crate::model::IcsAdapter::to_ics(self)
}
pub fn to_event_ics(&self) -> Vec<(String, String)> {
crate::model::IcsAdapter::to_event_ics(self)
}
pub fn advance_recurrence(&mut self) -> bool {
crate::model::RecurrenceEngine::advance(self)
}
pub fn to_smart_string(&self) -> String {
crate::model::TaskDisplay::to_smart_string(self)
}
pub fn format_duration_short(&self) -> String {
crate::model::TaskDisplay::format_duration_short(self)
}
pub fn checkbox_symbol(&self) -> &'static str {
crate::model::TaskDisplay::checkbox_symbol(self)
}
pub fn is_paused(&self) -> bool {
crate::model::TaskDisplay::is_paused(self)
}
}