use crate::model::{Alarm, DateType, Task};
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveTime, Utc};
use std::collections::{HashMap, HashSet};
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum SyntaxType {
Text,
Priority,
DueDate,
StartDate,
Recurrence,
Duration,
Tag,
Location,
Url,
Geo,
Description,
Reminder,
Calendar, Pin, Filter, Operator, }
#[derive(Debug)]
pub struct SyntaxToken {
pub kind: SyntaxType,
pub start: usize,
pub end: usize,
}
pub fn parse_alias_values(input: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut escaped = false;
let mut in_geo = false;
for c in input.chars() {
if escaped {
current.push(c);
escaped = false;
} else if c == '\\' {
escaped = true;
current.push(c);
} else if c == '"' {
in_quote = !in_quote;
current.push(c);
} else if c == ',' && !in_quote {
let trimmed = current.trim_start();
if trimmed.starts_with("geo:") && !in_geo {
in_geo = true;
current.push(c);
} else {
parts.push(current.trim().to_string());
current.clear();
in_geo = false;
}
} else {
current.push(c);
}
}
if !current.trim().is_empty() {
parts.push(current.trim().to_string());
}
parts.into_iter().filter(|s| !s.is_empty()).collect()
}
pub fn extract_inline_aliases(input: &str) -> (String, HashMap<String, Vec<String>>) {
let parts = split_input_respecting_quotes(input);
let mut cleaned_words = Vec::new();
let mut new_aliases = HashMap::new();
for (_, _, token) in parts {
if token.contains(":=")
&& !token.starts_with('\\')
&& let Some((left, right)) = token.split_once(":=")
{
let mut key = String::new();
let mut is_valid = false;
if left.starts_with('#') {
key = strip_quotes(left.trim_start_matches('#'));
is_valid = !key.is_empty();
}
else if left.starts_with("@@") || left.to_lowercase().starts_with("loc:") {
let raw = if left.starts_with("@@") {
left.trim_start_matches('@')
} else {
&left[4..]
};
let clean = strip_quotes(raw);
if !clean.is_empty() {
key = format!("@@{}", clean);
is_valid = true;
}
}
if is_valid {
let tags = parse_alias_values(right);
if !tags.is_empty() {
new_aliases.insert(key, tags);
cleaned_words.push(left.to_string());
continue;
}
}
}
cleaned_words.push(token);
}
(cleaned_words.join(" "), new_aliases)
}
pub fn is_valid_geo(val: &str) -> bool {
let stripped = strip_quotes(val);
let s = stripped.trim();
if s.eq_ignore_ascii_case("here") {
return true;
}
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 2 {
return false;
}
let valid_part = |p: &str| -> bool {
let p = p.trim();
!p.is_empty()
&& p.chars()
.all(|c| c.is_ascii_digit() || " .-°NSEWnsew".contains(c))
};
valid_part(parts[0]) && valid_part(parts[1])
}
pub fn validate_alias_integrity(
new_key: &str,
new_values: &[String],
current_aliases: &HashMap<String, Vec<String>>,
) -> Result<(), String> {
fn to_key(val: &str) -> Option<String> {
if val.starts_with('#') {
Some(strip_quotes(val.trim_start_matches('#')))
} else if val.starts_with("@@") {
Some(format!("@@{}", strip_quotes(val.trim_start_matches('@'))))
} else if val.to_lowercase().starts_with("loc:") {
Some(format!("@@{}", strip_quotes(&val[4..])))
} else {
None
}
}
if new_values
.iter()
.any(|v| to_key(v).as_deref() == Some(new_key))
{
return Err(format!("Alias '{}' cannot refer to itself.", new_key));
}
let mut stack: Vec<String> = new_values.iter().filter_map(|v| to_key(v)).collect();
let mut visited_path = HashSet::new();
while let Some(current_ref) = stack.pop() {
if current_ref == new_key {
return Err(format!(
"Circular dependency: '{}' leads back to itself.",
new_key
));
}
if visited_path.contains(¤t_ref) {
continue;
}
visited_path.insert(current_ref.clone());
let mut search = current_ref.as_str();
loop {
if let Some(children) = current_aliases.get(search) {
for child in children {
if let Some(k) = to_key(child) {
stack.push(k);
}
}
break;
}
if let Some(idx) = search.rfind(':') {
if search.starts_with("@@") && idx < 2 {
break;
}
search = &search[..idx];
} else {
break;
}
}
}
Ok(())
}
fn parse_time_string(s: &str) -> Option<NaiveTime> {
let lower = s.to_lowercase();
let parse_12h = |s: &str, is_pm: bool| -> Option<NaiveTime> {
let (h, m) = if let Some((h_str, m_str)) = s.split_once(':') {
(h_str.parse::<u32>().ok()?, m_str.parse::<u32>().ok()?)
} else {
(s.parse::<u32>().ok()?, 0)
};
if !(1..=12).contains(&h) || m > 59 {
return None;
}
let h_24 = if h == 12 {
if is_pm { 12 } else { 0 }
} else if is_pm {
h + 12
} else {
h
};
NaiveTime::from_hms_opt(h_24, m, 0)
};
if let Some(stripped) = lower.strip_suffix("am") {
return parse_12h(stripped, false);
}
if let Some(stripped) = lower.strip_suffix("pm") {
return parse_12h(stripped, true);
}
if let Some((h_str, m_str)) = lower.split_once(':') {
let h = h_str.parse::<u32>().ok()?;
let m = m_str.parse::<u32>().ok()?;
return NaiveTime::from_hms_opt(h, m, 0);
}
None
}
pub fn parse_time_range(s: &str) -> Option<(NaiveTime, NaiveTime)> {
let (start_str, end_str) = s.split_once('-')?;
let start = parse_time_string(start_str)?;
let end = parse_time_string(end_str)?;
Some((start, end))
}
fn is_time_format(s: &str) -> bool {
parse_time_string(s).is_some() || parse_time_range(s).is_some()
}
pub fn tokenize_smart_input(input: &str, is_search_query: bool) -> Vec<SyntaxToken> {
let mut tokens = Vec::new();
let words = split_input_respecting_quotes(input);
let mut cursor = 0;
let mut i = 0;
let mut has_recurrence = false;
while i < words.len() {
let (start, end, word) = &words[i];
if *start > cursor {
tokens.push(SyntaxToken {
kind: SyntaxType::Text,
start: cursor,
end: *start,
});
}
let mut matched_kind = None;
let mut words_consumed = 1;
let word_lower = word.to_lowercase();
if is_search_query {
if word == "|"
|| word == "("
|| word == ")"
|| word.eq_ignore_ascii_case("and")
|| word.eq_ignore_ascii_case("or")
|| word.eq_ignore_ascii_case("not")
{
matched_kind = Some(SyntaxType::Operator);
} else {
if word.starts_with('-') && word.len() > 1 {
matched_kind = Some(SyntaxType::Filter);
}
else if word_lower.starts_with("is:")
|| ((word.starts_with('!')
|| word.starts_with('~')
|| word.starts_with('@')
|| word.starts_with("due:")
|| word.starts_with('^')
|| word.starts_with("start:"))
&& (word.contains('<') || word.contains('>')))
{
matched_kind = Some(SyntaxType::Filter);
}
}
}
if matched_kind.is_none()
&& (word == "@every" || word == "rec:every" || word == "@after")
&& i + 1 < words.len()
{
let next_token_str = words[i + 1].2.as_str();
let next_next = if i + 2 < words.len() {
Some(words[i + 2].2.as_str())
} else {
None
};
if let Some((_, _, consumed)) = parse_amount_and_unit(next_token_str, next_next, false)
{
matched_kind = Some(SyntaxType::Recurrence);
words_consumed = 1 + 1 + consumed;
} else {
let parts: Vec<&str> = next_token_str.split(',').map(|s| s.trim()).collect();
let all_weekdays = parts.iter().all(|part| parse_weekday_code(part).is_some());
if all_weekdays && !parts.is_empty() {
matched_kind = Some(SyntaxType::Recurrence);
words_consumed = 2;
}
}
} else if matched_kind.is_none()
&& (word == "@daily"
|| word == "@weekly"
|| word == "@monthly"
|| word == "@yearly"
|| word == "rec:daily"
|| word == "rec:weekly"
|| word == "rec:monthly"
|| word == "rec:yearly")
{
matched_kind = Some(SyntaxType::Recurrence);
has_recurrence = true;
}
if matched_kind == Some(SyntaxType::Recurrence) {
has_recurrence = true;
}
if matched_kind == Some(SyntaxType::Recurrence) {
if i + words_consumed < words.len() {
let potential_time = &words[i + words_consumed].2;
if is_time_format(potential_time) {
words_consumed += 1;
}
}
}
if matched_kind.is_none()
&& has_recurrence
&& (word_lower == "until" || word_lower == "except")
&& i + 1 < words.len()
{
let next_token_str = words[i + 1].2.as_str();
let is_list = if next_token_str.contains(',') {
true } else if parse_smart_date(next_token_str).is_some()
|| parse_weekday_date(next_token_str).is_some()
{
if i + 2 < words.len() && is_time_format(&words[i + 2].2) {
words_consumed = 3; }
true
} else {
parse_weekday_code(next_token_str).is_some()
|| parse_month_code(next_token_str).is_some()
};
if is_list {
matched_kind = Some(SyntaxType::Recurrence);
if words_consumed == 1 {
words_consumed = 2;
}
}
}
if matched_kind.is_none() {
let (is_start, clean_word) = if let Some(val) = word.strip_prefix("^@") {
(true, val)
} else if let Some(val) = word
.strip_prefix("start:")
.or_else(|| word.strip_prefix('^'))
{
(true, val)
} else if let Some(val) = word.strip_prefix("due:").or_else(|| word.strip_prefix('@')) {
(false, val)
} else {
(false, "")
};
if !clean_word.is_empty() {
if clean_word == "next" && i + 1 < words.len() {
let next_str = words[i + 1].2.as_str();
let is_weekday = parse_weekday_code(next_str).is_some();
if is_date_unit_full(next_str) || is_weekday {
matched_kind = Some(if is_start {
SyntaxType::StartDate
} else {
SyntaxType::DueDate
});
words_consumed = 2;
if i + 2 < words.len() && is_time_format(&words[i + 2].2) {
words_consumed += 1;
}
}
}
else if clean_word == "in" && i + 1 < words.len() {
let next_token_str = words[i + 1].2.as_str();
let next_next = if i + 2 < words.len() {
Some(words[i + 2].2.as_str())
} else {
None
};
if let Some((_, _, consumed)) =
parse_amount_and_unit(next_token_str, next_next, false)
{
matched_kind = Some(if is_start {
SyntaxType::StartDate
} else {
SyntaxType::DueDate
});
words_consumed = 1 + 1 + consumed;
if i + words_consumed < words.len()
&& is_time_format(&words[i + words_consumed].2)
{
words_consumed += 1;
}
}
}
else {
if parse_smart_date(clean_word).is_some()
|| parse_weekday_code(clean_word).is_some()
|| parse_time_string(clean_word).is_some()
{
matched_kind = Some(if is_start {
SyntaxType::StartDate
} else {
SyntaxType::DueDate
});
if i + 1 < words.len() && is_time_format(&words[i + 1].2) {
words_consumed = 2;
}
}
}
}
}
if matched_kind.is_none() && word_lower.starts_with("rem:") {
matched_kind = Some(SyntaxType::Reminder);
let clean_val = if word.len() > 4 { &word[4..] } else { "" };
let find_next_token = |start_idx: usize| -> Option<usize> {
(start_idx..words.len()).find(|&idx| !words[idx].2.trim().is_empty())
};
if clean_val.eq_ignore_ascii_case("in")
|| (clean_val.is_empty()
&& find_next_token(i + 1)
.map(|idx| words[idx].2.eq_ignore_ascii_case("in"))
.unwrap_or(false))
{
let mut offset = 0;
if clean_val.is_empty() {
offset = 1;
}
if let Some(next_idx) = find_next_token(i + 1 + offset) {
let next_token_str = words[next_idx].2.as_str();
let next_next_idx = find_next_token(next_idx + 1);
let next_next = next_next_idx.map(|idx| words[idx].2.as_str());
if let Some((_, _, consumed)) =
parse_amount_and_unit(next_token_str, next_next, false)
{
let last_idx = if consumed > 0 {
next_next_idx.unwrap_or(next_idx)
} else {
next_idx
};
words_consumed = last_idx - i + 1;
}
}
}
else if clean_val.eq_ignore_ascii_case("next")
|| (clean_val.is_empty()
&& find_next_token(i + 1)
.map(|idx| words[idx].2.eq_ignore_ascii_case("next"))
.unwrap_or(false))
{
let mut offset = 0;
if clean_val.is_empty() {
offset = 1;
}
if let Some(next_idx) = find_next_token(i + 1 + offset) {
let next_word = &words[next_idx].2;
if parse_weekday_code(next_word).is_some() || is_date_unit_full(next_word) {
words_consumed = next_idx - i + 1;
if let Some(time_idx) = find_next_token(next_idx + 1)
&& is_time_format(&words[time_idx].2)
{
words_consumed = time_idx - i + 1;
}
}
}
}
else if !clean_val.is_empty()
&& (parse_smart_date(clean_val).is_some() || parse_next_date(clean_val).is_some())
{
if let Some(next_idx) = find_next_token(i + 1)
&& is_time_format(&words[next_idx].2)
{
words_consumed = next_idx - i + 1;
}
}
else if clean_val.is_empty()
&& let Some(next_idx) = find_next_token(i + 1)
{
let next_word = &words[next_idx].2;
if parse_smart_date(next_word).is_some() || parse_next_date(next_word).is_some() {
words_consumed = next_idx - i + 1;
if let Some(time_idx) = find_next_token(next_idx + 1)
&& is_time_format(&words[time_idx].2)
{
words_consumed = time_idx - i + 1;
}
} else if parse_duration(next_word).is_some() || is_time_format(next_word) {
words_consumed = next_idx - i + 1;
}
}
}
if matched_kind.is_none() {
if word.starts_with("@@") || word_lower.starts_with("loc:") {
matched_kind = Some(SyntaxType::Location);
} else if word_lower.starts_with("url:")
|| (word.starts_with("[[") && word.ends_with("]]"))
{
matched_kind = Some(SyntaxType::Url);
} else if word == "+cal" || word == "-cal" {
matched_kind = Some(SyntaxType::Calendar);
} else if word == "+pin" || word == "-pin" {
matched_kind = Some(SyntaxType::Pin);
} else if word_lower.starts_with("geo:") {
let mut temp_consumed = 1;
let mut raw_val = word[4..].to_string();
let is_geo_word = |w: &str| -> bool {
let upper = w.to_uppercase();
if matches!(
upper.as_str(),
"N" | "S" | "E" | "W" | "N," | "S," | "E," | "W,"
) {
return true;
}
if !w.chars().any(|c| c.is_ascii_digit()) {
return false;
}
w.chars()
.all(|c| c.is_ascii_digit() || " .,-°NSEWnsew".contains(c))
};
while i + temp_consumed < words.len() {
let next_word = &words[i + temp_consumed].2;
if is_geo_word(next_word) {
raw_val.push(' ');
raw_val.push_str(next_word);
temp_consumed += 1;
} else {
break;
}
}
if is_valid_geo(&raw_val) {
matched_kind = Some(SyntaxType::Geo);
words_consumed = temp_consumed;
}
} else if word_lower.starts_with("desc:") {
matched_kind = Some(SyntaxType::Description);
} else if word.starts_with('!') && word.len() > 1 && word[1..].parse::<u8>().is_ok() {
matched_kind = Some(SyntaxType::Priority);
} else if word.starts_with('~') || word_lower.starts_with("est:") {
matched_kind = Some(SyntaxType::Duration);
} else if word_lower.starts_with("spent:") {
matched_kind = Some(SyntaxType::Duration);
} else if word_lower.starts_with("done:") {
matched_kind = Some(SyntaxType::DueDate);
} else if word.starts_with('#') {
matched_kind = Some(SyntaxType::Tag);
}
}
if let Some(kind) = matched_kind {
let final_end = words[i + words_consumed - 1].1;
tokens.push(SyntaxToken {
kind,
start: *start,
end: final_end,
});
cursor = final_end;
i += words_consumed;
} else {
tokens.push(SyntaxToken {
kind: SyntaxType::Text,
start: *start,
end: *end,
});
cursor = *end;
i += 1;
}
}
if cursor < input.len() {
tokens.push(SyntaxToken {
kind: SyntaxType::Text,
start: cursor,
end: input.len(),
});
}
tokens
}
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next) = chars.next() {
out.push(next);
} else {
out.push('\\');
}
} else {
out.push(c);
}
}
out
}
pub fn strip_quotes(s: &str) -> String {
let s = s.trim();
if s.len() >= 2
&& ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('{') && s.ends_with('}')))
{
unescape(&s[1..s.len() - 1])
} else {
unescape(s)
}
}
pub fn quote_value(s: &str) -> String {
if s.contains(' ') || s.contains('"') || s.contains('\\') || s.contains('#') || s.is_empty() {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{}\"", escaped)
} else {
s.to_string()
}
}
fn split_input_respecting_quotes(input: &str) -> Vec<(usize, usize, String)> {
let mut parts = Vec::new();
let mut current = String::new();
let mut start_idx = 0;
let mut in_quote = false;
let mut brace_depth: usize = 0;
let mut escaped = false;
let chars = input.char_indices().peekable();
for (idx, c) in chars {
if current.is_empty() && !in_quote && brace_depth == 0 && !c.is_whitespace() {
start_idx = idx;
}
if escaped {
current.push(c);
escaped = false;
continue;
}
match c {
'\\' => {
escaped = true;
current.push('\\');
}
'"' if brace_depth == 0 => {
in_quote = !in_quote;
current.push(c);
}
'{' if !in_quote => {
brace_depth += 1;
current.push(c);
}
'}' if !in_quote => {
brace_depth = brace_depth.saturating_sub(1);
current.push(c);
}
ws if ws.is_whitespace() && !in_quote && brace_depth == 0 => {
if !current.is_empty() {
parts.push((start_idx, idx, current.clone()));
current.clear();
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
parts.push((start_idx, input.len(), current));
}
parts
}
pub fn expand_braces(input: &str) -> Vec<String> {
let mut results = Vec::new();
expand_braces_recursive("", input.trim(), &mut results);
results
}
fn split_by_comma_at_depth_zero(input: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0;
let mut last_idx = 0;
let mut in_quote = false;
let mut escaped = false;
for (i, c) in input.char_indices() {
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if c == '"' {
in_quote = !in_quote;
}
if !in_quote {
if c == '{' {
depth += 1;
} else if c == '}' {
if depth > 0 {
depth -= 1;
}
} else if c == ',' && depth == 0 {
parts.push(&input[last_idx..i]);
last_idx = i + c.len_utf8();
}
}
}
parts.push(&input[last_idx..]);
parts
}
fn expand_braces_recursive(prefix: &str, input: &str, results: &mut Vec<String>) {
let parts = split_by_comma_at_depth_zero(input);
for part in parts {
let part = part.trim();
if part.is_empty() {
continue;
}
let mut brace_start = None;
let mut depth = 0;
let mut in_quote = false;
let mut escaped = false;
for (i, c) in part.char_indices() {
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if c == '"' {
in_quote = !in_quote;
}
if !in_quote {
if c == '{' {
if depth == 0 {
brace_start = Some(i);
break;
}
depth += 1;
} else if c == '}' && depth > 0 {
depth -= 1;
}
}
}
if let Some(start) = brace_start
&& part.ends_with('}')
{
let base = part[..start].trim();
let inner = &part[start + 1..part.len() - 1];
let new_prefix = if base.is_empty() {
prefix.to_string()
} else {
let sep = if base.ends_with('=')
|| base.ends_with(':')
|| prefix.is_empty()
|| prefix.ends_with(':')
{
""
} else {
":"
};
format!("{}{}{}", prefix, sep, base)
};
let pass_prefix = if new_prefix.is_empty() {
String::new()
} else if new_prefix.ends_with('=') || new_prefix.ends_with(':') {
new_prefix
} else {
format!("{}:", new_prefix)
};
expand_braces_recursive(&pass_prefix, inner, results);
continue;
}
results.push(format!("{}{}", prefix, part));
}
}
fn collect_alias_expansions(
token: &str,
aliases: &HashMap<String, Vec<String>>,
visited: &mut HashSet<String>,
depth: usize,
) -> Vec<String> {
if depth > 10 {
return Vec::new();
}
let mut results = Vec::new();
let mut search_key: Option<String> = None;
if token.starts_with('#') {
search_key = Some(strip_quotes(token.trim_start_matches('#')));
}
else if token.starts_with("@@") || token.to_lowercase().starts_with("loc:") {
let raw = if token.starts_with("@@") {
token.trim_start_matches("@@")
} else {
&token[4..]
};
let clean = strip_quotes(raw);
if !clean.is_empty() {
search_key = Some(format!("@@{}", clean));
}
}
if let Some(key) = search_key {
let mut search = key.as_str();
let mut found_values = None;
let mut matched_key = String::new();
loop {
if let Some(vals) = aliases.get(search) {
found_values = Some(vals);
matched_key = search.to_string();
break;
}
if let Some(idx) = search.rfind(':') {
if idx > 0 {
search = &search[..idx];
} else {
break;
}
} else {
break;
}
}
if let Some(values) = found_values {
if visited.contains(&matched_key) {
return results;
}
visited.insert(matched_key);
for val in values {
let child_expansions = collect_alias_expansions(val, aliases, visited, depth + 1);
results.extend(child_expansions);
results.push(val.clone());
}
}
}
results
}
fn is_date_unit_full(s: &str) -> bool {
let lower = s.to_lowercase();
matches!(
lower.as_str(),
"day" | "days" | "week" | "weeks" | "month" | "months" | "year" | "years"
)
}
fn is_date_unit_short(s: &str) -> bool {
let lower = s.to_lowercase();
matches!(
lower.as_str(),
"m" | "min"
| "minute"
| "minutes"
| "h"
| "hour"
| "hours"
| "d"
| "day"
| "days"
| "w"
| "week"
| "weeks"
| "mo"
| "month"
| "months"
| "y"
| "year"
| "years"
)
}
fn parse_amount_and_unit(
first: &str,
second: Option<&str>,
strict_unit: bool,
) -> Option<(u32, String, usize)> {
if let Some(next_token) = second
&& let Some(amt) = parse_english_number(first)
{
let unit = next_token.to_lowercase();
let is_valid = if strict_unit {
is_date_unit_full(&unit)
} else {
is_date_unit_short(&unit)
};
if is_valid {
return Some((amt, unit, 1));
}
}
if !strict_unit {
let lower = first.to_lowercase();
let (amt_str, unit_str) = if let Some(idx) = lower.find(|c: char| !c.is_numeric()) {
lower.split_at(idx)
} else {
return None;
};
if let Ok(amt) = amt_str.parse::<u32>()
&& is_date_unit_short(unit_str)
{
return Some((amt, unit_str.to_string(), 0));
}
}
None
}
fn parse_english_number(s: &str) -> Option<u32> {
match s.to_lowercase().as_str() {
"one" | "1" => Some(1),
"two" | "2" => Some(2),
"three" | "3" => Some(3),
"four" | "4" => Some(4),
"five" | "5" => Some(5),
"six" | "6" => Some(6),
"seven" | "7" => Some(7),
"eight" | "8" => Some(8),
"nine" | "9" => Some(9),
"ten" | "10" => Some(10),
"eleven" | "11" => Some(11),
"twelve" | "12" => Some(12),
_ => s.parse::<u32>().ok(),
}
}
fn parse_freq_from_unit(u: &str) -> &'static str {
let s = u.to_lowercase();
if s.starts_with('d') {
"DAILY"
} else if s.starts_with('w') {
"WEEKLY"
} else if s.starts_with("mo") {
"MONTHLY"
} else if s.starts_with('y') {
"YEARLY"
} else {
""
}
}
fn code_to_full_day(code: &str) -> &'static str {
match code {
"MO" => "monday",
"TU" => "tuesday",
"WE" => "wednesday",
"TH" => "thursday",
"FR" => "friday",
"SA" => "saturday",
"SU" => "sunday",
_ => "",
}
}
fn month_num_to_short_name(num: u32) -> &'static str {
match num {
1 => "jan",
2 => "feb",
3 => "mar",
4 => "apr",
5 => "may",
6 => "jun",
7 => "jul",
8 => "aug",
9 => "sep",
10 => "oct",
11 => "nov",
12 => "dec",
_ => "",
}
}
pub fn prettify_recurrence(rrule: &str, is_relative: bool) -> String {
let mut freq = "";
let mut interval = "";
let mut byday = "";
let mut bymonth = "";
let mut until = "";
for part in rrule.split(';') {
if let Some(v) = part.strip_prefix("FREQ=") {
freq = v;
} else if let Some(v) = part.strip_prefix("INTERVAL=") {
interval = v;
} else if let Some(v) = part.strip_prefix("BYDAY=") {
byday = v;
} else if let Some(v) = part.strip_prefix("BYMONTH=") {
bymonth = v;
} else if let Some(v) = part.strip_prefix("UNTIL=") {
until = v;
}
}
let mut until_str = String::new();
if !until.is_empty() {
let date_part = until.split('T').next().unwrap_or(until);
if date_part.len() >= 8 {
until_str = format!(
" until {}-{}-{}",
&date_part[0..4],
&date_part[4..6],
&date_part[6..8]
);
} else {
until_str = format!(" until {}", until);
}
}
if freq == "WEEKLY" && !byday.is_empty() && (interval.is_empty() || interval == "1") {
let days: Vec<&str> = byday.split(',').collect();
let all_codes = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
if days.len() == 1 && bymonth.is_empty() {
let d_name = code_to_full_day(days[0]);
if !d_name.is_empty() {
let prefix = if is_relative { "@after" } else { "@every" };
return format!("{} {}{}", prefix, d_name, until_str);
}
}
if days.len() >= 2 && days.len() <= 3 && bymonth.is_empty() {
let day_names: Vec<String> = days
.iter()
.map(|code| code_to_full_day(code).to_string())
.filter(|name| !name.is_empty())
.collect();
if day_names.len() == days.len() {
let prefix = if is_relative { "@after" } else { "@every" };
return format!("{} {}{}", prefix, day_names.join(","), until_str);
}
}
if days.len() >= 4 {
let missing_days: Vec<String> = all_codes
.iter()
.filter(|code| !days.contains(code))
.map(|code| code_to_full_day(code).to_string())
.collect();
if !bymonth.is_empty() {
let months: Vec<u32> = bymonth
.split(',')
.filter_map(|m| m.parse::<u32>().ok())
.collect();
let missing_months: Vec<String> = (1..=12)
.filter(|m| !months.contains(m))
.map(|m| month_num_to_short_name(m).to_string())
.collect();
if !missing_months.is_empty() {
let mut combined = missing_days;
combined.extend(missing_months);
return format!("@daily{} except {}", until_str, combined.join(","));
}
}
if missing_days.is_empty() {
if bymonth.is_empty() {
return format!("@daily{}", until_str);
}
} else {
return format!("@daily{} except {}", until_str, missing_days.join(","));
}
}
}
if !bymonth.is_empty() && (interval.is_empty() || interval == "1") {
let months: Vec<u32> = bymonth
.split(',')
.filter_map(|m| m.parse::<u32>().ok())
.collect();
let missing: Vec<String> = (1..=12)
.filter(|m| !months.contains(m))
.map(|m| month_num_to_short_name(m).to_string())
.collect();
if missing.is_empty() {
} else {
let base = match freq {
"DAILY" => "@daily",
"WEEKLY" => "@weekly",
"MONTHLY" => "@monthly",
"YEARLY" => "@yearly",
_ => "",
};
if !base.is_empty() {
return format!("{}{} except {}", base, until_str, missing.join(","));
}
}
}
if !freq.is_empty() && !interval.is_empty() && interval != "1" {
let unit = match freq {
"DAILY" => {
if is_relative {
"d"
} else {
"days"
}
}
"WEEKLY" => {
if is_relative {
"w"
} else {
"weeks"
}
}
"MONTHLY" => {
if is_relative {
"mo"
} else {
"months"
}
}
"YEARLY" => {
if is_relative {
"y"
} else {
"years"
}
}
_ => "",
};
if !unit.is_empty() {
if is_relative {
return format!("@after {}{}{}", interval, unit, until_str);
} else {
return format!("@every {} {}{}", interval, unit, until_str);
}
}
}
if !freq.is_empty() {
if is_relative {
let unit = match freq {
"DAILY" => "1d",
"WEEKLY" => "1w",
"MONTHLY" => "1mo",
"YEARLY" => "1y",
_ => "",
};
if !unit.is_empty() {
return format!("@after {}{}", unit, until_str);
}
} else {
let base = match freq {
"DAILY" => "@daily",
"WEEKLY" => "@weekly",
"MONTHLY" => "@monthly",
"YEARLY" => "@yearly",
_ => "",
};
if !base.is_empty() {
return format!("{}{}", base, until_str);
}
}
}
format!("rec:{}", rrule)
}
fn parse_single_duration(val: &str) -> Option<u32> {
let lower = val.to_lowercase();
if let Some(n) = lower.strip_suffix("min") {
return n.parse::<u32>().ok();
}
if let Some(n) = lower.strip_suffix('m') {
return n.parse::<u32>().ok();
} else if let Some(n) = lower.strip_suffix('h') {
return n.parse::<u32>().ok().map(|h| h * 60);
} else if let Some(n) = lower.strip_suffix('d') {
return n.parse::<u32>().ok().map(|d| d * 24 * 60);
} else if let Some(n) = lower.strip_suffix('w') {
return n.parse::<u32>().ok().map(|w| w * 7 * 24 * 60);
} else if let Some(n) = lower.strip_suffix("mo") {
return n.parse::<u32>().ok().map(|mo| mo * 30 * 24 * 60);
} else if let Some(n) = lower.strip_suffix('y') {
return n.parse::<u32>().ok().map(|y| y * 365 * 24 * 60);
}
None
}
pub fn parse_duration(val: &str) -> Option<u32> {
parse_single_duration(val)
}
pub fn parse_duration_range(val: &str) -> Option<(u32, Option<u32>)> {
if let Some((left, right)) = val.split_once('-') {
let min = parse_single_duration(left)?;
let max = parse_single_duration(right)?;
if max >= min {
return Some((min, Some(max)));
}
return Some((min, None));
}
let single = parse_single_duration(val)?;
Some((single, None))
}
pub fn format_duration_compact(mins: u32) -> String {
if mins == 0 {
return "".to_string();
}
if mins.is_multiple_of(525600) {
format!("{}y", mins / 525600)
} else if mins.is_multiple_of(43200) {
format!("{}mo", mins / 43200)
} else if mins.is_multiple_of(10080) {
format!("{}w", mins / 10080)
} else if mins.is_multiple_of(1440) {
format!("{}d", mins / 1440)
} else if mins.is_multiple_of(60) {
format!("{}h", mins / 60)
} else {
format!("{}m", mins)
}
}
fn parse_recurrence(val: &str) -> Option<String> {
match val.to_lowercase().as_str() {
"daily" => Some("FREQ=DAILY".to_string()),
"weekly" => Some("FREQ=WEEKLY".to_string()),
"monthly" => Some("FREQ=MONTHLY".to_string()),
"yearly" => Some("FREQ=YEARLY".to_string()),
_ => {
if val.to_uppercase().starts_with("FREQ=") {
Some(val.to_string())
} else {
None
}
}
}
}
fn parse_smart_date(val: &str) -> Option<DateType> {
if let Ok(date) = NaiveDate::parse_from_str(val, "%Y-%m-%d") {
return Some(DateType::AllDay(date));
}
if val.len() == 8
&& val.chars().all(|c| c.is_numeric())
&& let Ok(date) = NaiveDate::parse_from_str(val, "%Y%m%d")
{
return Some(DateType::AllDay(date));
}
if val.len() == 7 && val.chars().nth(4) == Some('-') {
let y = val[0..4].parse::<i32>().ok();
let m = val[5..7].parse::<u32>().ok();
if let (Some(year), Some(month)) = (y, m)
&& (1..=12).contains(&month)
{
return Some(DateType::Month(year, month));
}
}
if val.len() == 4
&& val.chars().all(|c| c.is_numeric())
&& let Ok(y) = val.parse::<i32>()
{
return Some(DateType::Year(y));
}
let now = Local::now().date_naive();
let lower = val.to_lowercase();
if lower == "today" {
return Some(DateType::AllDay(now));
}
if lower == "tomorrow" {
return Some(DateType::AllDay(now + Duration::days(1)));
}
if let Some(n) = lower.strip_suffix('d').and_then(|s| s.parse::<i64>().ok()) {
return Some(DateType::AllDay(now + Duration::days(n)));
}
if let Some(n) = lower.strip_suffix('w').and_then(|s| s.parse::<i64>().ok()) {
return Some(DateType::AllDay(now + Duration::days(n * 7)));
}
if let Some(n) = lower.strip_suffix("mo").and_then(|s| s.parse::<i64>().ok()) {
return Some(DateType::AllDay(now + Duration::days(n * 30)));
}
if let Some(n) = lower.strip_suffix('y').and_then(|s| s.parse::<i64>().ok()) {
return Some(DateType::AllDay(now + Duration::days(n * 365)));
}
None
}
fn parse_weekday_code(s: &str) -> Option<&'static str> {
match s.to_lowercase().as_str() {
"mo" | "mon" | "monday" | "mondays" => Some("MO"),
"tu" | "tue" | "tuesday" | "tuesdays" => Some("TU"),
"we" | "wed" | "wednesday" | "wednesdays" => Some("WE"),
"th" | "thu" | "thursday" | "thursdays" => Some("TH"),
"fr" | "fri" | "friday" | "fridays" => Some("FR"),
"sa" | "sat" | "saturday" | "saturdays" => Some("SA"),
"su" | "sun" | "sunday" | "sundays" => Some("SU"),
_ => None,
}
}
fn parse_month_code(s: &str) -> Option<u32> {
match s.to_lowercase().as_str() {
"jan" | "january" => Some(1),
"feb" | "february" => Some(2),
"mar" | "march" => Some(3),
"apr" | "april" => Some(4),
"may" => Some(5),
"jun" | "june" => Some(6),
"jul" | "july" => Some(7),
"aug" | "august" => Some(8),
"sep" | "sept" | "september" => Some(9),
"oct" | "october" => Some(10),
"nov" | "november" => Some(11),
"dec" | "december" => Some(12),
_ => None,
}
}
fn parse_weekday_date(s: &str) -> Option<NaiveDate> {
parse_next_date(s)
}
fn parse_next_date(unit: &str) -> Option<NaiveDate> {
let now = Local::now().date_naive();
match unit.to_lowercase().as_str() {
"week" => Some(now + Duration::days(7)),
"month" => Some(now + Duration::days(30)),
"year" => Some(now + Duration::days(365)),
_ => {
if let Some(code) = parse_weekday_code(unit) {
let target = match code {
"MO" => chrono::Weekday::Mon,
"TU" => chrono::Weekday::Tue,
"WE" => chrono::Weekday::Wed,
"TH" => chrono::Weekday::Thu,
"FR" => chrono::Weekday::Fri,
"SA" => chrono::Weekday::Sat,
"SU" => chrono::Weekday::Sun,
_ => return None,
};
return next_weekday(now, target);
}
None
}
}
}
fn parse_in_date(amount: u32, unit: &str) -> Option<NaiveDate> {
let now = Local::now().date_naive();
let days = match unit.to_lowercase().as_str() {
"d" | "day" | "days" => amount as i64,
"w" | "week" | "weeks" => amount as i64 * 7,
"mo" | "month" | "months" => amount as i64 * 30,
"y" | "year" | "years" => amount as i64 * 365,
_ => return None,
};
Some(now + Duration::days(days))
}
fn next_weekday(from: NaiveDate, target: chrono::Weekday) -> Option<NaiveDate> {
let mut d = from + Duration::days(1);
while d.weekday() != target {
d += Duration::days(1);
}
Some(d)
}
fn calculate_first_occurrence(rrule: &str, today: NaiveDate) -> NaiveDate {
let mut freq = "";
let mut byday = "";
let mut bymonth = "";
for part in rrule.split(';') {
if let Some(v) = part.strip_prefix("FREQ=") {
freq = v;
} else if let Some(v) = part.strip_prefix("BYDAY=") {
byday = v;
} else if let Some(v) = part.strip_prefix("BYMONTH=") {
bymonth = v;
}
}
if freq == "WEEKLY" && !byday.is_empty() {
let days: Vec<&str> = byday.split(',').collect();
let allowed_months: Vec<u32> = if !bymonth.is_empty() {
bymonth
.split(',')
.filter_map(|m| m.parse::<u32>().ok())
.collect()
} else {
vec![]
};
let is_month_allowed = |date: NaiveDate| -> bool {
allowed_months.is_empty() || allowed_months.contains(&date.month())
};
let mut earliest: Option<NaiveDate> = None;
for day_code in days {
let weekday = match day_code {
"MO" => chrono::Weekday::Mon,
"TU" => chrono::Weekday::Tue,
"WE" => chrono::Weekday::Wed,
"TH" => chrono::Weekday::Thu,
"FR" => chrono::Weekday::Fri,
"SA" => chrono::Weekday::Sat,
"SU" => chrono::Weekday::Sun,
_ => continue,
};
if today.weekday() == weekday && is_month_allowed(today) {
return today;
}
let mut candidate = today;
for _ in 0..60 {
if let Some(next) = next_weekday(candidate, weekday) {
if is_month_allowed(next) {
if earliest.is_none() || next < earliest.unwrap() {
earliest = Some(next);
}
break;
}
candidate = next;
} else {
break;
}
}
}
if let Some(date) = earliest {
return date;
}
}
if (freq == "MONTHLY" || freq == "DAILY" || freq == "WEEKLY") && !bymonth.is_empty() {
let allowed_months: Vec<u32> = bymonth
.split(',')
.filter_map(|m| m.parse::<u32>().ok())
.collect();
if !allowed_months.is_empty() {
let current_month = today.month();
if allowed_months.contains(¤t_month) {
return today;
}
let mut check_date = today;
for _ in 0..12 {
if check_date.month() == 12 {
check_date = NaiveDate::from_ymd_opt(check_date.year() + 1, 1, 1).unwrap();
} else {
check_date =
NaiveDate::from_ymd_opt(check_date.year(), check_date.month() + 1, 1)
.unwrap();
}
if allowed_months.contains(&check_date.month()) {
return check_date;
}
}
}
}
today
}
fn finalize_date_token(
d: DateType,
stream: &[String],
next_idx: usize,
consumed: &mut usize,
) -> (DateType, Option<DateType>) {
match d {
DateType::AllDay(nd) => {
if next_idx < stream.len() {
let next_token = &stream[next_idx];
if let Some((t1, t2)) = parse_time_range(next_token) {
*consumed += 1;
let mut end_nd = nd;
if t2 < t1 {
end_nd += Duration::days(1);
}
return (
DateType::Specific(crate::model::item::safe_local_to_utc(nd, t1)),
Some(DateType::Specific(crate::model::item::safe_local_to_utc(
end_nd, t2,
))),
);
} else if let Some(t) = parse_time_string(next_token) {
*consumed += 1;
return (
DateType::Specific(crate::model::item::safe_local_to_utc(nd, t)),
None,
);
}
}
(DateType::AllDay(nd), None)
}
_ => (d, None),
}
}
pub fn normalize_geo(val: String) -> String {
if val.eq_ignore_ascii_case("here") {
return val;
}
if !val.contains('°')
&& !val.contains('N')
&& !val.contains('S')
&& !val.contains('E')
&& !val.contains('W')
&& !val.contains('n')
&& !val.contains('s')
&& !val.contains('e')
&& !val.contains('w')
{
return val.replace(' ', "");
}
let clean = val.replace(['°', ' '], "");
let mut parts = clean.split(',');
if let (Some(lat_str), Some(lon_str)) = (parts.next(), parts.next()) {
let parse_part = |s: &str| -> String {
let mut num = String::new();
let mut sign = "";
for c in s.chars() {
if c == 'S' || c == 's' || c == 'W' || c == 'w' {
sign = "-";
} else if c.is_numeric() || c == '.' || c == '-' {
num.push(c);
}
}
format!("{}{}", sign, num)
};
return format!("{},{}", parse_part(lat_str), parse_part(lon_str));
}
val.replace(' ', "")
}
pub fn escape_summary(summary: &str) -> String {
let mut escaped_words = Vec::new();
for word in summary.split_whitespace() {
if is_special_token(word) {
escaped_words.push(format!("\\{}", word));
} else {
escaped_words.push(word.to_string());
}
}
escaped_words.join(" ")
}
fn is_special_token(word: &str) -> bool {
let lower = word.to_lowercase();
if word.starts_with('@')
|| word.starts_with('#')
|| word.starts_with('!')
|| word.starts_with('^')
|| word.starts_with('~')
{
return true;
}
if lower.starts_with("loc:")
|| lower.starts_with("url:")
|| lower.starts_with("geo:")
|| lower.starts_with("desc:")
|| lower.starts_with("due:")
|| lower.starts_with("start:")
|| lower.starts_with("rec:")
|| lower.starts_with("est:")
|| lower.starts_with("rem:")
|| lower.starts_with("spent:")
|| lower.starts_with("until")
|| lower.starts_with("except")
|| lower == "+pin"
|| lower == "-pin"
{
return true;
}
false
}
pub fn apply_smart_input(
task: &mut Task,
input: &str,
aliases: &HashMap<String, Vec<String>>,
default_reminder_time: Option<NaiveTime>,
) {
let mut summary_words = Vec::new();
task.unmapped_properties
.retain(|p| p.key != "X-CFAIT-RECUR-FROM-COMPLETION");
task.priority = 0;
task.due = None;
task.dtstart = None;
task.rrule = None;
task.estimated_duration = None;
task.estimated_duration_max = None;
task.location = None;
task.url = None;
task.geo = None;
task.categories.clear();
task.alarms.clear();
task.exdates.clear();
task.percent_complete = None;
enum PendingAlarm {
Relative(u32),
TimeOnly(NaiveTime),
Absolute(DateTime<Utc>),
}
let mut pending_alarms = Vec::new();
let user_tokens: Vec<String> = split_input_respecting_quotes(input)
.into_iter()
.map(|(_, _, s)| s)
.collect();
let mut background_tokens = Vec::new();
let mut visited = HashSet::new();
for token in &user_tokens {
let expanded = collect_alias_expansions(token, aliases, &mut visited, 0);
background_tokens.extend(expanded);
}
let mut stream = background_tokens;
stream.extend(user_tokens);
let mut blocked_weekdays = HashSet::new();
let mut blocked_months = HashSet::new();
let mut has_recurrence = false;
let mut i = 0;
while i < stream.len() {
let token = &stream[i];
let mut consumed = 1;
let token_lower = token.to_lowercase();
if (token == "rec:every" || token == "@every" || token == "@after") && i + 1 < stream.len()
{
let next_token_str = stream[i + 1].as_str();
let next_next = if i + 2 < stream.len() {
Some(stream[i + 2].as_str())
} else {
None
};
if let Some((interval, unit, extra_consumed)) =
parse_amount_and_unit(next_token_str, next_next, false)
{
let freq = parse_freq_from_unit(&unit);
if !freq.is_empty() {
task.rrule = Some(format!("FREQ={};INTERVAL={}", freq, interval));
if token == "@after"
&& !task
.unmapped_properties
.iter()
.any(|p| p.key == "X-CFAIT-RECUR-FROM-COMPLETION")
{
task.unmapped_properties.push(crate::model::RawProperty {
key: "X-CFAIT-RECUR-FROM-COMPLETION".to_string(),
value: "TRUE".to_string(),
params: vec![],
});
}
has_recurrence = true;
consumed = 1 + 1 + extra_consumed;
} else {
summary_words.push(unescape(token));
}
} else {
let parts: Vec<&str> = next_token_str.split(',').map(|s| s.trim()).collect();
let weekday_codes: Vec<String> = parts
.iter()
.filter_map(|part| parse_weekday_code(part).map(|s| s.to_string()))
.collect();
if !weekday_codes.is_empty() && weekday_codes.len() == parts.len() {
task.rrule = Some(format!("FREQ=WEEKLY;BYDAY={}", weekday_codes.join(",")));
if token == "@after"
&& !task
.unmapped_properties
.iter()
.any(|p| p.key == "X-CFAIT-RECUR-FROM-COMPLETION")
{
task.unmapped_properties.push(crate::model::RawProperty {
key: "X-CFAIT-RECUR-FROM-COMPLETION".to_string(),
value: "TRUE".to_string(),
params: vec![],
});
}
has_recurrence = true;
consumed = 2;
} else {
summary_words.push(unescape(token));
}
}
if let Some(rrule_str) = task.rrule.clone() {
if i + consumed < stream.len() {
let potential_time = &stream[i + consumed];
if let Some(t) = parse_time_string(potential_time) {
let today = Local::now().date_naive();
let first_date = calculate_first_occurrence(&rrule_str, today);
let dt_specific = crate::model::item::safe_local_to_utc(first_date, t);
let date_val = DateType::Specific(dt_specific);
task.due = Some(date_val.clone());
task.dtstart = Some(date_val);
consumed += 1;
}
}
}
} else if let Some(rrule) = token
.strip_prefix("rec:")
.and_then(parse_recurrence)
.or_else(|| token.strip_prefix('@').and_then(parse_recurrence))
.or_else(|| {
if token_lower.starts_with("freq=") {
Some(token.to_string()) } else {
None
}
})
{
task.rrule = Some(rrule.clone());
has_recurrence = true;
if i + consumed < stream.len() {
let potential_time = &stream[i + consumed];
if let Some(t) = parse_time_string(potential_time) {
let today = Local::now().date_naive();
let first_date = calculate_first_occurrence(&rrule, today);
let dt_specific = crate::model::item::safe_local_to_utc(first_date, t);
let date_val = DateType::Specific(dt_specific);
task.due = Some(date_val.clone());
task.dtstart = Some(date_val);
consumed += 1;
}
}
}
else if has_recurrence && token_lower == "until" && i + 1 < stream.len() {
let next_token = &stream[i + 1];
if let Some(d) = parse_smart_date(next_token) {
if let Some(mut rr) = task.rrule.take() {
if !rr.contains("UNTIL=") {
let date_str = match d {
DateType::AllDay(nd) => nd.format("%Y%m%d").to_string(),
DateType::Specific(dt) => dt.format("%Y%m%d").to_string(),
DateType::Month(y, m) => format!("{:04}{:02}01", y, m),
DateType::Year(y) => format!("{:04}0101", y),
};
rr.push_str(&format!(";UNTIL={}", date_str));
}
task.rrule = Some(rr);
}
consumed = 2;
} else {
summary_words.push(unescape(token));
}
}
else if has_recurrence && token_lower == "except" && i + 1 < stream.len() {
let next_token = &stream[i + 1];
let parts: Vec<&str> = next_token.split(',').map(|s| s.trim()).collect();
let mut matched_any = false;
if parts.len() == 1 {
let part = parts[0];
let mut temp_consumed = 1;
if let Some(d) = parse_smart_date(part) {
let (dt, _) = finalize_date_token(d, &stream, i + 2, &mut temp_consumed);
task.exdates.push(dt);
matched_any = true;
} else if let Some(code) = parse_weekday_code(part) {
blocked_weekdays.insert(code.to_string());
matched_any = true;
} else if let Some(m) = parse_month_code(part) {
blocked_months.insert(m);
matched_any = true;
}
if matched_any {
consumed = 1 + temp_consumed; } else {
eprintln!(
"DEBUG: Failed to parse part '{}' as date/weekday/month",
part
);
}
} else {
for part in parts {
if let Some(d) = parse_smart_date(part) {
let all_day_date = match d {
DateType::AllDay(nd) => DateType::AllDay(nd),
DateType::Specific(dt) => DateType::AllDay(dt.date_naive()),
DateType::Month(y, m) => {
DateType::AllDay(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
}
DateType::Year(y) => {
DateType::AllDay(NaiveDate::from_ymd_opt(y, 1, 1).unwrap())
}
};
task.exdates.push(all_day_date); matched_any = true;
} else if let Some(code) = parse_weekday_code(part) {
blocked_weekdays.insert(code.to_string());
matched_any = true;
} else if let Some(m) = parse_month_code(part) {
blocked_months.insert(m);
matched_any = true;
}
}
if matched_any {
consumed = 2;
}
}
if !matched_any {
summary_words.push(unescape(token));
}
}
else if let Some(val) = token_lower.strip_prefix("rem:") {
let clean_val = if val.is_empty() && i + 1 < stream.len() {
consumed += 1;
&stream[i + 1]
} else {
if token.len() > 4 { &token[4..] } else { "" }
};
if clean_val.eq_ignore_ascii_case("in") && i + consumed < stream.len() {
let next_str = &stream[i + consumed];
let next_next = if i + consumed + 1 < stream.len() {
Some(stream[i + consumed + 1].as_str())
} else {
None
};
if let Some((amt, unit, extra)) = parse_amount_and_unit(next_str, next_next, false)
{
let mins = match unit.as_str() {
"d" | "day" | "days" => amt * 1440,
"w" | "week" | "weeks" => amt * 10080,
"h" | "hour" | "hours" => amt * 60,
_ => amt, };
let now = Local::now();
let target = now + Duration::minutes(mins as i64);
let target_utc = target.with_timezone(&Utc);
pending_alarms.push(PendingAlarm::Absolute(target_utc));
consumed += 1 + extra; } else {
summary_words.push(unescape(token));
}
}
else if clean_val.eq_ignore_ascii_case("next") && i + consumed < stream.len() {
let next_str = &stream[i + consumed];
if let Some(target_date) = parse_next_date(next_str) {
consumed += 1;
let mut time = default_reminder_time
.unwrap_or_else(|| NaiveTime::from_hms_opt(9, 0, 0).unwrap());
if i + consumed < stream.len()
&& let Some(t) = parse_time_string(&stream[i + consumed])
{
time = t;
consumed += 1;
}
let local_dt = crate::model::item::safe_local_to_utc(target_date, time);
pending_alarms.push(PendingAlarm::Absolute(local_dt));
} else {
summary_words.push(unescape(token));
}
} else if !clean_val.is_empty() {
if let Some(d) = parse_duration(clean_val) {
pending_alarms.push(PendingAlarm::Relative(d));
}
else if let Some(t) = parse_time_string(clean_val) {
pending_alarms.push(PendingAlarm::TimeOnly(t));
}
else if let Some(d) = parse_smart_date(clean_val)
.or_else(|| parse_next_date(clean_val).map(DateType::AllDay))
{
let mut time_part = None;
if i + consumed < stream.len() {
let potential_time = &stream[i + consumed];
if let Some(t) = parse_time_string(potential_time) {
time_part = Some(t);
consumed += 1;
}
}
let fallback = default_reminder_time
.unwrap_or_else(|| NaiveTime::from_hms_opt(8, 0, 0).unwrap());
let t = time_part.unwrap_or(fallback);
let dt = match d {
DateType::AllDay(nd) => crate::model::item::safe_local_to_utc(nd, t),
DateType::Specific(dt) => dt,
DateType::Month(y, m) => crate::model::item::safe_local_to_utc(
NaiveDate::from_ymd_opt(y, m, 1).unwrap(),
t,
),
DateType::Year(y) => crate::model::item::safe_local_to_utc(
NaiveDate::from_ymd_opt(y, 1, 1).unwrap(),
t,
),
};
pending_alarms.push(PendingAlarm::Absolute(dt));
} else {
summary_words.push(unescape(token));
}
} else {
summary_words.push(unescape(token));
}
}
else if let Some(_val) = token_lower.strip_prefix("done:") {
let clean_val = if token.len() > 5 { &token[5..] } else { "" };
let mut matched = false;
if !clean_val.is_empty() {
if clean_val.ends_with('%') {
if let Ok(pc) = clean_val.trim_end_matches('%').parse::<u8>() {
task.percent_complete = Some(pc.min(100));
consumed = 1;
matched = true;
}
}
else if let Ok(ndt) =
chrono::NaiveDateTime::parse_from_str(clean_val, "%Y-%m-%d %H:%M")
{
let utc_dt = crate::model::item::safe_local_to_utc(ndt.date(), ndt.time());
task.set_completion_date(Some(utc_dt));
consumed = 1;
matched = true;
} else if let Some(d) = parse_smart_date(clean_val) {
let mut temp_consumed = 1;
let (dt, _) =
finalize_date_token(d, &stream, i + temp_consumed, &mut temp_consumed);
let utc_dt = match dt {
DateType::Specific(t) => t,
DateType::AllDay(d) => crate::model::item::safe_local_to_utc(
d,
chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
),
DateType::Month(y, m) => {
let d = NaiveDate::from_ymd_opt(y, m, 1).unwrap();
crate::model::item::safe_local_to_utc(
d,
chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
)
}
DateType::Year(y) => {
let d = NaiveDate::from_ymd_opt(y, 1, 1).unwrap();
crate::model::item::safe_local_to_utc(
d,
chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
)
}
};
task.set_completion_date(Some(utc_dt));
consumed = temp_consumed;
matched = true;
}
}
if !matched {
summary_words.push(unescape(token));
}
} else if let Some(val) = token_lower.strip_prefix("spent:") {
if let Some(mins) = parse_duration(val) {
task.time_spent_seconds = (mins as u64) * 60;
} else {
summary_words.push(unescape(token));
}
} else if token.starts_with("@@") {
let is_triple = token.starts_with("@@@");
let val = strip_quotes(token.trim_start_matches('@'));
if val.is_empty() {
summary_words.push(unescape(token));
} else {
task.location = Some(val.clone());
if is_triple {
summary_words.push(val);
}
}
} else if token_lower.starts_with("loc:") {
let val = strip_quotes(&token[4..]);
if val.is_empty() {
summary_words.push(unescape(token));
} else {
task.location = Some(val);
}
} else if token_lower.starts_with("url:") {
task.url = Some(strip_quotes(&token[4..]));
} else if token.starts_with("[[") && token.ends_with("]]") {
task.url = Some(token[2..token.len() - 2].to_string());
} else if token == "+cal" {
task.create_event = Some(true);
} else if token == "-cal" {
task.create_event = Some(false);
} else if token == "+pin" {
task.pinned = true;
} else if token == "-pin" {
task.pinned = false;
} else if token_lower.starts_with("geo:") {
let mut raw_val = token[4..].to_string();
let mut temp_consumed = 1;
let is_geo_word = |w: &str| -> bool {
let upper = w.to_uppercase();
if matches!(
upper.as_str(),
"N" | "S" | "E" | "W" | "N," | "S," | "E," | "W,"
) {
return true;
}
if !w.chars().any(|c| c.is_ascii_digit()) {
return false;
}
w.chars()
.all(|c| c.is_ascii_digit() || " .,-°NSEWnsew".contains(c))
};
while i + temp_consumed < stream.len() {
let next_token = &stream[i + temp_consumed];
if is_geo_word(next_token) {
raw_val.push(' ');
raw_val.push_str(next_token);
temp_consumed += 1;
} else {
break;
}
}
if is_valid_geo(&raw_val) {
task.geo = Some(normalize_geo(strip_quotes(&raw_val)));
consumed = temp_consumed;
} else {
summary_words.push(unescape(token));
}
} else if token_lower.starts_with("desc:") {
let desc_val = strip_quotes(&token[5..]);
if task.description.is_empty() {
task.description = desc_val;
} else {
task.description.push_str(&format!("\n{}", desc_val));
}
} else if token.starts_with('#') {
let is_double = token.starts_with("##");
let cat_expr = token.trim_start_matches('#');
if !cat_expr.is_empty() {
let expanded_cats = expand_braces(cat_expr);
for cat in expanded_cats {
let clean_cat = strip_quotes(&cat);
if !clean_cat.is_empty() && !task.categories.contains(&clean_cat) {
task.categories.push(clean_cat);
}
}
if is_double {
summary_words.push(strip_quotes(cat_expr));
}
}
} else if token.starts_with('!') && token.len() > 1 {
if let Ok(p) = token[1..].parse::<u8>() {
task.priority = p.min(9);
} else {
summary_words.push(unescape(token));
}
} else if token.starts_with('~') || token_lower.starts_with("est:") {
let val = strip_quotes(if let Some(s) = token.strip_prefix('~') {
s
} else {
&token[4..]
});
if let Some((min, max_opt)) = parse_duration_range(&val) {
task.estimated_duration = Some(min);
task.estimated_duration_max = max_opt;
} else {
summary_words.push(unescape(token));
}
}
else if (token.starts_with('@')
|| token.starts_with('^')
|| token_lower.starts_with("due:")
|| token_lower.starts_with("start:"))
&& stream.get(i + 1).is_some()
{
let (set_start, set_due, clean) = if let Some(s) = token.strip_prefix("^@") {
(true, true, s)
} else if let Some(_v) = token
.strip_prefix('^')
.or_else(|| token_lower.strip_prefix("start:"))
{
let clean_v = if let Some(s) = token.strip_prefix('^') {
s
} else {
&token[6..]
};
(true, false, clean_v)
} else {
let clean_v = if let Some(s) = token.strip_prefix('@') {
s
} else if token_lower.starts_with("due:") {
&token[4..]
} else {
""
};
(false, true, clean_v)
};
let mut matched_date = false;
if clean == "next" && stream.get(i + 1).is_some() {
let next_str = &stream[i + 1];
if let Some(d) = parse_next_date(next_str) {
let mut temp_consumed = 2; let (dt, dt_end) = finalize_date_token(
DateType::AllDay(d),
&stream,
i + temp_consumed,
&mut temp_consumed,
);
if let Some(end) = dt_end {
task.dtstart = Some(dt);
task.due = Some(end);
} else {
if set_start {
task.dtstart = Some(dt.clone());
}
if set_due {
task.due = Some(dt);
}
}
consumed = temp_consumed;
matched_date = true;
}
}
else if clean == "in" && i + 1 < stream.len() {
let next_token_str = stream[i + 1].as_str();
let next_next = if i + 2 < stream.len() {
Some(stream[i + 2].as_str())
} else {
None
};
if let Some((amount, unit, extra)) =
parse_amount_and_unit(next_token_str, next_next, false)
&& let Some(d) = parse_in_date(amount, &unit)
{
let mut temp_consumed = 1 + 1 + extra;
let (dt, dt_end) = finalize_date_token(
DateType::AllDay(d),
&stream,
i + temp_consumed,
&mut temp_consumed,
);
if let Some(end) = dt_end {
task.dtstart = Some(dt);
task.due = Some(end);
} else {
if set_start {
task.dtstart = Some(dt.clone());
}
if set_due {
task.due = Some(dt);
}
}
consumed = temp_consumed;
matched_date = true;
}
}
if !matched_date {
if let Some(d) = parse_smart_date(clean) {
let mut temp_consumed = 1;
let (dt, dt_end) =
finalize_date_token(d, &stream, i + temp_consumed, &mut temp_consumed);
if let Some(end) = dt_end {
task.dtstart = Some(dt);
task.due = Some(end);
} else {
if set_start {
task.dtstart = Some(dt.clone());
}
if set_due {
task.due = Some(dt);
}
}
consumed = temp_consumed;
} else if let Some((t1, t2)) = parse_time_range(clean) {
let now_local = Local::now();
let mut target_date = now_local.date_naive();
if t1 <= now_local.time() {
target_date += Duration::days(1);
}
let dt1 =
DateType::Specific(crate::model::item::safe_local_to_utc(target_date, t1));
let mut target_date_end = target_date;
if t2 < t1 {
target_date_end += Duration::days(1);
}
let dt2 = DateType::Specific(crate::model::item::safe_local_to_utc(
target_date_end,
t2,
));
task.dtstart = Some(dt1);
task.due = Some(dt2);
} else if let Some(t) = parse_time_string(clean) {
let now_local = Local::now();
let mut target_date = now_local.date_naive();
if t <= now_local.time() {
target_date += Duration::days(1);
}
let dt = crate::model::item::safe_local_to_utc(target_date, t);
let dt_type = DateType::Specific(dt);
if set_start {
task.dtstart = Some(dt_type.clone());
}
if set_due {
task.due = Some(dt_type);
}
} else if let Some(d) = parse_weekday_date(clean) {
let mut temp_consumed = 1;
let (dt, dt_end) = finalize_date_token(
DateType::AllDay(d),
&stream,
i + temp_consumed,
&mut temp_consumed,
);
if let Some(end) = dt_end {
task.dtstart = Some(dt);
task.due = Some(end);
} else {
if set_start {
task.dtstart = Some(dt.clone());
}
if set_due {
task.due = Some(dt);
}
}
consumed = temp_consumed;
} else if let Some(rrule) = parse_recurrence(clean) {
task.rrule = Some(rrule);
has_recurrence = true;
} else {
summary_words.push(unescape(token));
}
}
}
else if let Some(val) = token
.strip_prefix("rec:")
.or_else(|| token.strip_prefix('@'))
.or_else(|| token.strip_prefix("^@"))
{
let (set_start, set_due) = if token.starts_with("^@") {
(true, true)
} else {
(false, true) };
if let Some(rrule) = parse_recurrence(val) {
task.rrule = Some(rrule.clone());
has_recurrence = true;
if i + consumed < stream.len() {
let potential_time = &stream[i + consumed];
if let Some(t) = parse_time_string(potential_time) {
let today = Local::now().date_naive();
let first_date = calculate_first_occurrence(&rrule, today);
let dt_specific = crate::model::item::safe_local_to_utc(first_date, t);
let date_val = DateType::Specific(dt_specific);
if set_start {
task.dtstart = Some(date_val.clone());
}
if set_due {
task.due = Some(date_val.clone());
}
if !set_start {
task.dtstart = Some(date_val.clone());
}
if !set_due {
task.due = Some(date_val.clone());
}
consumed += 1;
}
}
} else if token.starts_with("rec:") {
if let Some((interval, unit, _)) = parse_amount_and_unit(val, None, false) {
let freq = parse_freq_from_unit(&unit);
if !freq.is_empty() {
let rrule_str = format!("FREQ={};INTERVAL={}", freq, interval);
task.rrule = Some(rrule_str.clone());
has_recurrence = true;
if i + consumed < stream.len() {
let potential_time = &stream[i + consumed];
if let Some(t) = parse_time_string(potential_time) {
let today = Local::now().date_naive();
let first_date = calculate_first_occurrence(&rrule_str, today);
let dt_specific =
crate::model::item::safe_local_to_utc(first_date, t);
let date_val = DateType::Specific(dt_specific);
task.due = Some(date_val.clone());
task.dtstart = Some(date_val);
consumed += 1;
}
}
} else {
summary_words.push(unescape(token));
}
} else {
summary_words.push(unescape(token));
}
} else if let Some(d) = parse_smart_date(val) {
let mut temp_consumed = 1;
let (dt, dt_end) =
finalize_date_token(d, &stream, i + temp_consumed, &mut temp_consumed);
if let Some(end) = dt_end {
task.dtstart = Some(dt);
task.due = Some(end);
} else {
if set_start {
task.dtstart = Some(dt.clone());
}
if set_due {
task.due = Some(dt);
}
}
consumed = temp_consumed;
} else if let Some((t1, t2)) = parse_time_range(val) {
let now_local = Local::now();
let mut target_date = now_local.date_naive();
if t1 <= now_local.time() {
target_date += Duration::days(1);
}
let dt1 =
DateType::Specific(crate::model::item::safe_local_to_utc(target_date, t1));
let mut target_date_end = target_date;
if t2 < t1 {
target_date_end += Duration::days(1);
}
let dt2 =
DateType::Specific(crate::model::item::safe_local_to_utc(target_date_end, t2));
task.dtstart = Some(dt1);
task.due = Some(dt2);
} else if let Some(rrule) = parse_recurrence(val) {
task.rrule = Some(rrule);
has_recurrence = true;
} else if let Some(t) = parse_time_string(val) {
let now_local = Local::now();
let mut target_date = now_local.date_naive();
if t <= now_local.time() {
target_date += Duration::days(1);
}
let dt = crate::model::item::safe_local_to_utc(target_date, t);
let dt_type = DateType::Specific(dt);
if set_start {
task.dtstart = Some(dt_type.clone());
}
if set_due {
task.due = Some(dt_type);
}
} else if let Some(d) = parse_weekday_date(val) {
let mut temp_consumed = 1;
let (dt, dt_end) = finalize_date_token(
DateType::AllDay(d),
&stream,
i + temp_consumed,
&mut temp_consumed,
);
if let Some(end) = dt_end {
task.dtstart = Some(dt);
task.due = Some(end);
} else {
if set_start {
task.dtstart = Some(dt.clone());
}
if set_due {
task.due = Some(dt);
}
}
consumed = temp_consumed;
} else if let Some((t1, t2)) = parse_time_range(val) {
let now_local = Local::now();
let mut target_date = now_local.date_naive();
if t1 <= now_local.time() {
target_date += Duration::days(1);
}
let dt1 =
DateType::Specific(crate::model::item::safe_local_to_utc(target_date, t1));
let mut target_date_end = target_date;
if t2 < t1 {
target_date_end += Duration::days(1);
}
let dt2 =
DateType::Specific(crate::model::item::safe_local_to_utc(target_date_end, t2));
task.dtstart = Some(dt1);
task.due = Some(dt2);
} else if let Some(_stripped) = token_lower.strip_prefix("due:") {
let real_val = &token[4..];
if let Some(d) = parse_smart_date(real_val) {
let mut temp_consumed = 1;
let (dt, _) =
finalize_date_token(d, &stream, i + temp_consumed, &mut temp_consumed);
task.due = Some(dt);
consumed = temp_consumed;
} else {
summary_words.push(unescape(token));
}
} else {
summary_words.push(unescape(token));
}
} else if token_lower.starts_with("due:") {
let val = &token[4..];
if let Some(d) = parse_smart_date(val) {
let mut temp_consumed = 1;
let (dt, _) =
finalize_date_token(d, &stream, i + temp_consumed, &mut temp_consumed);
task.due = Some(dt);
consumed = temp_consumed;
} else {
summary_words.push(unescape(token));
}
} else if let Some(_val) = token
.strip_prefix('^')
.or_else(|| token_lower.strip_prefix("start:"))
{
let clean_val = if let Some(s) = token.strip_prefix('^') {
s
} else {
&token[6..]
};
if let Some(d) = parse_smart_date(clean_val) {
let mut temp_consumed = 1;
let (dt, _) =
finalize_date_token(d, &stream, i + temp_consumed, &mut temp_consumed);
task.dtstart = Some(dt);
consumed = temp_consumed;
} else if let Some(d) = parse_weekday_date(clean_val) {
let mut temp_consumed = 1;
let (dt, _) = finalize_date_token(
DateType::AllDay(d),
&stream,
i + temp_consumed,
&mut temp_consumed,
);
task.dtstart = Some(dt);
consumed = temp_consumed;
} else {
summary_words.push(unescape(token));
}
} else {
summary_words.push(unescape(token));
}
i += consumed;
}
if (!blocked_weekdays.is_empty() || !blocked_months.is_empty())
&& let Some(mut rrule) = task.rrule.take()
{
if !blocked_weekdays.is_empty() {
if rrule.contains("FREQ=DAILY") {
rrule = rrule.replace("FREQ=DAILY", "FREQ=WEEKLY");
}
if !rrule.contains("BYDAY=") {
let all_days = vec!["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
let allowed: Vec<&str> = all_days
.into_iter()
.filter(|d| !blocked_weekdays.contains(*d))
.collect();
if !allowed.is_empty() {
rrule.push_str(&format!(";BYDAY={}", allowed.join(",")));
}
}
}
if !blocked_months.is_empty() && !rrule.contains("BYMONTH=") {
let allowed: Vec<String> = (1..=12)
.filter(|m| !blocked_months.contains(m))
.map(|m| m.to_string())
.collect();
if !allowed.is_empty() {
rrule.push_str(&format!(";BYMONTH={}", allowed.join(",")));
}
}
task.rrule = Some(rrule);
}
task.summary = summary_words.join(" ");
task.categories.sort();
task.categories.dedup();
if let Some(ref rrule) = task.rrule
&& task.dtstart.is_none()
&& task.due.is_none()
{
let today = Local::now().date_naive();
let first_occurrence = calculate_first_occurrence(rrule, today);
task.dtstart = Some(DateType::AllDay(first_occurrence));
task.due = Some(DateType::AllDay(first_occurrence));
}
if let (Some(start), Some(due)) = (&task.dtstart, &task.due)
&& start.to_start_comparison_time() > due.to_comparison_time()
{
task.dtstart = Some(due.clone());
}
for pending in pending_alarms {
match pending {
PendingAlarm::Relative(mins) => {
task.alarms.push(Alarm::new_relative(mins));
}
PendingAlarm::Absolute(dt) => {
task.alarms.push(Alarm::new_absolute(dt));
}
PendingAlarm::TimeOnly(t) => {
let anchor_date = task
.due
.as_ref()
.or(task.dtstart.as_ref())
.map(|d| d.to_date_naive());
if let Some(target_date) = anchor_date {
let dt = crate::model::item::safe_local_to_utc(target_date, t);
task.alarms.push(Alarm::new_absolute(dt));
} else {
let now_local = Local::now();
let mut target_date = now_local.date_naive();
if t <= now_local.time() {
target_date += Duration::days(1);
}
let dt = crate::model::item::safe_local_to_utc(target_date, t);
task.alarms.push(Alarm::new_absolute(dt));
}
}
}
}
}
pub fn parse_session_input(input: &str) -> Option<crate::model::item::WorkSession> {
use chrono::Local;
let now = Local::now();
let mut target_date = now.date_naive();
let tokens: Vec<&str> = input.split_whitespace().collect();
if tokens.is_empty() {
return None;
}
let mut duration_mins = None;
let mut start_time = None;
let mut end_time = None;
for token in tokens {
let lower = token.to_lowercase();
if lower == "today" {
target_date = now.date_naive();
} else if lower == "yesterday" {
target_date = now.date_naive() - chrono::Duration::days(1);
} else if let Ok(d) = chrono::NaiveDate::parse_from_str(&lower, "%Y-%m-%d") {
target_date = d;
} else if lower.contains('-') && lower.split('-').count() == 2 {
let parts: Vec<&str> = lower.split('-').collect();
if let (Some(t1), Some(t2)) = (parse_time_string(parts[0]), parse_time_string(parts[1]))
{
start_time = Some(t1);
end_time = Some(t2);
}
} else if let Some(mins) = parse_duration(&lower) {
duration_mins = Some(mins);
} else if let Some(t) = parse_time_string(&lower) {
start_time = Some(t);
}
}
if let (Some(s), Some(e)) = (start_time, end_time) {
let start_dt = crate::model::item::safe_local_to_utc(target_date, s);
let mut end_target_date = target_date;
if e < s {
end_target_date += chrono::Duration::days(1);
}
let end_dt = crate::model::item::safe_local_to_utc(end_target_date, e);
return Some(crate::model::item::WorkSession {
start: start_dt.timestamp(),
end: end_dt.timestamp(),
});
}
if let Some(dur) = duration_mins {
if let Some(s) = start_time {
let start_dt = crate::model::item::safe_local_to_utc(target_date, s);
let end_dt = start_dt + chrono::Duration::minutes(dur as i64);
return Some(crate::model::item::WorkSession {
start: start_dt.timestamp(),
end: end_dt.timestamp(),
});
} else if target_date == now.date_naive() {
let end_dt = now.with_timezone(&chrono::Utc);
let start_dt = end_dt - chrono::Duration::minutes(dur as i64);
return Some(crate::model::item::WorkSession {
start: start_dt.timestamp(),
end: end_dt.timestamp(),
});
} else {
let s = chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap();
let start_dt = crate::model::item::safe_local_to_utc(target_date, s);
let end_dt = start_dt + chrono::Duration::minutes(dur as i64);
return Some(crate::model::item::WorkSession {
start: start_dt.timestamp(),
end: end_dt.timestamp(),
});
}
}
None
}