Compare commits
45 Commits
letterbox-
...
renovate/l
| Author | SHA1 | Date | |
|---|---|---|---|
| ce62608920 | |||
| a0ef96aa1a | |||
| 9d05f74280 | |||
| b9df41559f | |||
| 4bb5307904 | |||
| 0cbe860d0d | |||
| 6eaedfaae8 | |||
| d1787bac32 | |||
| 58554e7f40 | |||
| fa6fe673bd | |||
| 44961f6ef1 | |||
| cd09594347 | |||
| 3d09ab7c15 | |||
| 0cf3e3ce05 | |||
| d10a34e32e | |||
| f311e517a9 | |||
| aacee2f537 | |||
| e2bec7760b | |||
| a4ef7e48a6 | |||
| 1aa6f22461 | |||
| 2f5026c75b | |||
| dcb90ca2c8 | |||
| 772548f10d | |||
| c62e925016 | |||
| 4570a6ea1c | |||
| ae06df21a0 | |||
| 02d43feb79 | |||
| 0e6508498a | |||
| a94bd8a341 | |||
| 788baf9e86 | |||
| fdf910b1a1 | |||
| 714c94e40b | |||
| 667893b6a3 | |||
| 687b050410 | |||
| 48bad8cbb0 | |||
| d156fe8282 | |||
| fc66759e92 | |||
| fcdc2d56a9 | |||
| 60993abd6f | |||
| c1112e5538 | |||
| 1b59c7a287 | |||
| 17cdae7bfb | |||
| f89135fce5 | |||
| 38c1d140bd | |||
| 197ea049b2 |
439
Cargo.lock
generated
439
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ authors = ["Bill Thiede <git@xinu.tv>"]
|
||||
edition = "2021"
|
||||
license = "UNLICENSED"
|
||||
publish = ["xinu"]
|
||||
version = "0.17.60"
|
||||
version = "0.17.66"
|
||||
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
||||
|
||||
[profile.dev]
|
||||
|
||||
@@ -56,7 +56,7 @@ urlencoding = "2.1.3"
|
||||
#xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
|
||||
#xtracing = { path = "../../xtracing" }
|
||||
xtracing = { version = "0.3.2", registry = "xinu" }
|
||||
zip = "7.0.0"
|
||||
zip = { version = "7.0.0", default-features = false, features = ["aes-crypto", "bzip2", "deflate64", "deflate", "time", "zstd"] }
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -178,11 +178,36 @@ pub fn extract_calendar_metadata_from_mail(
|
||||
let month = &caps[2];
|
||||
let day = &caps[3];
|
||||
let year = &caps[4];
|
||||
let start_hour: u32 = caps[5].parse().unwrap_or(0);
|
||||
let start_min: u32 = caps.get(6).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
|
||||
let start_ampm = &caps[7];
|
||||
let end_hour: u32 = caps[8].parse().unwrap_or(0);
|
||||
let end_min: u32 = caps.get(9).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
|
||||
let end_ampm = &caps[10];
|
||||
|
||||
// Convert 12-hour to 24-hour format
|
||||
let start_hour_24 = if start_ampm == "pm" && start_hour != 12 {
|
||||
start_hour + 12
|
||||
} else if start_ampm == "am" && start_hour == 12 {
|
||||
0
|
||||
} else {
|
||||
start_hour
|
||||
};
|
||||
let end_hour_24 = if end_ampm == "pm" && end_hour != 12 {
|
||||
end_hour + 12
|
||||
} else if end_ampm == "am" && end_hour == 12 {
|
||||
0
|
||||
} else {
|
||||
end_hour
|
||||
};
|
||||
|
||||
let date_str = format!("{} {} {}", month, day, year);
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%b %d %Y") {
|
||||
let ymd = date.format("%Y%m%d").to_string();
|
||||
start_date = Some(ymd.clone());
|
||||
end_date = Some(ymd);
|
||||
// Store date with time in YYYYMMDDTHHMMSS format for start/end
|
||||
let start_dt = format!("{}T{:02}{:02}00", date.format("%Y%m%d"), start_hour_24, start_min);
|
||||
let end_dt = format!("{}T{:02}{:02}00", date.format("%Y%m%d"), end_hour_24, end_min);
|
||||
start_date = Some(start_dt);
|
||||
end_date = Some(end_dt);
|
||||
}
|
||||
} else {
|
||||
// Pattern: from Thu Sep 11 to Fri Jan 30, 2026
|
||||
@@ -375,13 +400,37 @@ pub fn extract_calendar_metadata_from_mail(
|
||||
let (local_fmt_start, local_fmt_end) = if let (Some(ref start_str), Some(ref end_str)) =
|
||||
(&start_date, &end_date)
|
||||
{
|
||||
// Parse YYYYMMDD format dates
|
||||
let start_d = NaiveDate::parse_from_str(start_str, "%Y%m%d").ok();
|
||||
let end_d = NaiveDate::parse_from_str(end_str, "%Y%m%d").ok();
|
||||
// Parse dates - try YYYYMMDDTHHMMSS format first (with time), then YYYYMMDD (date only)
|
||||
let (start_d, start_time) = if start_str.contains('T') {
|
||||
let parts: Vec<&str> = start_str.split('T').collect();
|
||||
let date = NaiveDate::parse_from_str(parts[0], "%Y%m%d").ok();
|
||||
let time = if parts.len() > 1 {
|
||||
chrono::NaiveTime::parse_from_str(parts[1], "%H%M%S").ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(date, time)
|
||||
} else {
|
||||
(NaiveDate::parse_from_str(start_str, "%Y%m%d").ok(), None)
|
||||
};
|
||||
|
||||
let (end_d, end_time) = if end_str.contains('T') {
|
||||
let parts: Vec<&str> = end_str.split('T').collect();
|
||||
let date = NaiveDate::parse_from_str(parts[0], "%Y%m%d").ok();
|
||||
let time = if parts.len() > 1 {
|
||||
chrono::NaiveTime::parse_from_str(parts[1], "%H%M%S").ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(date, time)
|
||||
} else {
|
||||
(NaiveDate::parse_from_str(end_str, "%Y%m%d").ok(), None)
|
||||
};
|
||||
|
||||
if let (Some(start), Some(end)) = (start_d, end_d) {
|
||||
// For all-day events, end date is exclusive, so we need to subtract one day
|
||||
let display_end = if end > start {
|
||||
// For all-day events (no time), end date is exclusive, so we need to subtract one day
|
||||
let is_allday = start_time.is_none() && end_time.is_none();
|
||||
let display_end = if is_allday && end > start {
|
||||
end.pred_opt().unwrap_or(end)
|
||||
} else {
|
||||
end
|
||||
@@ -398,9 +447,17 @@ pub fn extract_calendar_metadata_from_mail(
|
||||
}
|
||||
}
|
||||
|
||||
// Format dates for display
|
||||
let fmt_start = start.format("%a %b %e, %Y").to_string();
|
||||
let fmt_end = display_end.format("%a %b %e, %Y").to_string();
|
||||
// Format dates for display - include time if available
|
||||
let fmt_start = if let Some(t) = start_time {
|
||||
format!("{} {}", start.format("%a %b %e, %Y"), t.format("%-I:%M %p"))
|
||||
} else {
|
||||
start.format("%a %b %e, %Y").to_string()
|
||||
};
|
||||
let fmt_end = if let Some(t) = end_time {
|
||||
format!("{} {}", display_end.format("%a %b %e, %Y"), t.format("%-I:%M %p"))
|
||||
} else {
|
||||
display_end.format("%a %b %e, %Y").to_string()
|
||||
};
|
||||
(fmt_start, fmt_end)
|
||||
} else {
|
||||
(start_val.clone(), end_val.clone())
|
||||
@@ -2395,9 +2452,9 @@ mod tests {
|
||||
assert_eq!(meta.summary, Some("Dentist appt".to_string()));
|
||||
// Organizer: from From header, extract email address
|
||||
assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string()));
|
||||
// Dates: should extract Sep 23, 2025, 3pm-4pm
|
||||
assert_eq!(meta.start_date, Some("20250923".to_string()));
|
||||
assert_eq!(meta.end_date, Some("20250923".to_string()));
|
||||
// Dates: should extract Sep 23, 2025, 3pm-4pm (15:00-16:00)
|
||||
assert_eq!(meta.start_date, Some("20250923T150000".to_string()));
|
||||
assert_eq!(meta.end_date, Some("20250923T160000".to_string()));
|
||||
// Should not be recurring
|
||||
if let Some(ref html) = meta.body_html {
|
||||
assert!(
|
||||
@@ -2625,13 +2682,17 @@ mod tests {
|
||||
assert_eq!(meta.summary, Some("painting class".to_string()));
|
||||
assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string()));
|
||||
// Dates: Thursday Feb 12, 2026 7pm - 9pm (same day event with time)
|
||||
assert_eq!(meta.start_date, Some("20260212".to_string()));
|
||||
assert_eq!(meta.end_date, Some("20260212".to_string()));
|
||||
// Start: 7pm = 19:00, End: 9pm = 21:00
|
||||
assert_eq!(meta.start_date, Some("20260212T190000".to_string()));
|
||||
assert_eq!(meta.end_date, Some("20260212T210000".to_string()));
|
||||
// Assert ical summary is rendered and shows Feb 12 highlighted
|
||||
let html = meta.body_html.expect("body_html");
|
||||
println!("Rendered HTML: {}", html);
|
||||
assert!(html.contains("ical-flex"), "Calendar widget should be rendered");
|
||||
assert!(html.contains(r#"data-event-day="2026-02-12""#), "Feb 12 should be highlighted");
|
||||
// Verify time is displayed in the HTML
|
||||
assert!(html.contains("7:00 PM") || html.contains("7pm") || html.contains("19:00"),
|
||||
"HTML should contain the start time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -224,6 +224,24 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
});
|
||||
}
|
||||
Msg::AddTag(query, tag) => {
|
||||
orders.skip().perform_cmd(async move {
|
||||
let res: Result<
|
||||
graphql_client::Response<graphql::add_tag_mutation::ResponseData>,
|
||||
gloo_net::Error,
|
||||
> = send_graphql(graphql::AddTagMutation::build_query(
|
||||
graphql::add_tag_mutation::Variables {
|
||||
query: query.clone(),
|
||||
tag: tag.clone(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
if let Err(e) = res {
|
||||
error!("Failed to add tag {tag} to {query}: {e}");
|
||||
}
|
||||
Msg::Refresh
|
||||
});
|
||||
}
|
||||
Msg::AddTagAndGoToSearch(query, tag) => {
|
||||
orders.skip().perform_cmd(async move {
|
||||
let res: Result<
|
||||
graphql_client::Response<graphql::add_tag_mutation::ResponseData>,
|
||||
@@ -256,7 +274,24 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
if let Err(e) = res {
|
||||
error!("Failed to remove tag {tag} to {query}: {e}");
|
||||
}
|
||||
// TODO: reconsider this behavior
|
||||
Msg::Refresh
|
||||
});
|
||||
}
|
||||
Msg::RemoveTagAndGoToSearch(query, tag) => {
|
||||
orders.skip().perform_cmd(async move {
|
||||
let res: Result<
|
||||
graphql_client::Response<graphql::remove_tag_mutation::ResponseData>,
|
||||
gloo_net::Error,
|
||||
> = send_graphql(graphql::RemoveTagMutation::build_query(
|
||||
graphql::remove_tag_mutation::Variables {
|
||||
query: query.clone(),
|
||||
tag: tag.clone(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
if let Err(e) = res {
|
||||
error!("Failed to remove tag {tag} to {query}: {e}");
|
||||
}
|
||||
Msg::GoToSearchResults
|
||||
});
|
||||
}
|
||||
@@ -505,7 +540,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
.join(" ");
|
||||
orders
|
||||
.skip()
|
||||
.perform_cmd(async move { Msg::AddTag(threads, tag) });
|
||||
.perform_cmd(async move { Msg::AddTagAndGoToSearch(threads, tag) });
|
||||
}
|
||||
}
|
||||
Msg::SelectionRemoveTag(tag) => {
|
||||
@@ -520,7 +555,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
.join(" ");
|
||||
orders
|
||||
.skip()
|
||||
.perform_cmd(async move { Msg::RemoveTag(threads, tag) });
|
||||
.perform_cmd(async move { Msg::RemoveTagAndGoToSearch(threads, tag) });
|
||||
}
|
||||
}
|
||||
Msg::SelectionMarkAsRead => {
|
||||
@@ -692,6 +727,13 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
};
|
||||
orders.send_msg(Msg::CatchupNext);
|
||||
}
|
||||
Msg::CatchupMarkAsSpam => {
|
||||
if let Some(thread_id) = current_thread_id(&model.context) {
|
||||
orders.send_msg(Msg::AddTag(thread_id.clone(), "Spam".to_string()));
|
||||
orders.send_msg(Msg::SetUnread(thread_id, false));
|
||||
};
|
||||
orders.send_msg(Msg::CatchupNext);
|
||||
}
|
||||
Msg::CatchupNext => {
|
||||
orders.send_msg(Msg::ScrollToTop);
|
||||
let Some(catchup) = &mut model.catchup else {
|
||||
@@ -852,7 +894,10 @@ pub enum Msg {
|
||||
|
||||
SetUnread(String, bool),
|
||||
AddTag(String, String),
|
||||
AddTagAndGoToSearch(String, String),
|
||||
#[allow(dead_code)]
|
||||
RemoveTag(String, String),
|
||||
RemoveTagAndGoToSearch(String, String),
|
||||
Snooze(String, DateTime<Utc>),
|
||||
|
||||
FrontPageRequest {
|
||||
@@ -902,6 +947,7 @@ pub enum Msg {
|
||||
CatchupStart,
|
||||
CatchupKeepUnread,
|
||||
CatchupMarkAsRead,
|
||||
CatchupMarkAsSpam,
|
||||
CatchupNext,
|
||||
CatchupExit,
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ pub fn view(model: &Model) -> Node<Msg> {
|
||||
&catchup.items,
|
||||
is_loading,
|
||||
model.read_completion_ratio,
|
||||
true, // show spam button for email
|
||||
)
|
||||
} else {
|
||||
normal_view(
|
||||
@@ -127,6 +128,7 @@ pub fn view(model: &Model) -> Node<Msg> {
|
||||
&catchup.items,
|
||||
is_loading,
|
||||
model.read_completion_ratio,
|
||||
false, // no spam button for news
|
||||
)
|
||||
} else {
|
||||
normal_view(
|
||||
@@ -193,6 +195,7 @@ fn catchup_view(
|
||||
items: &[CatchupItem],
|
||||
is_loading: bool,
|
||||
read_completion_ratio: f64,
|
||||
show_spam_button: bool,
|
||||
) -> Node<Msg> {
|
||||
div![
|
||||
C!["w-full", "relative", "text-white"],
|
||||
@@ -268,6 +271,14 @@ fn catchup_view(
|
||||
Msg::GoToSearchResults
|
||||
]))
|
||||
],
|
||||
IF!(show_spam_button => button![
|
||||
tw_classes::button(),
|
||||
C!["text-red-500"],
|
||||
attrs! {At::Title => "Mark as spam"},
|
||||
span![i![C!["far", "fa-hand"]]],
|
||||
span![C!["pl-2"], "Spam"],
|
||||
ev(Ev::Click, |_| Msg::CatchupMarkAsSpam)
|
||||
]),
|
||||
button![
|
||||
tw_classes::button_with_color("bg-green-800", "hover:bg-green-700"),
|
||||
span![i![C!["far", "fa-envelope-open"]]],
|
||||
@@ -450,7 +461,7 @@ fn removable_tags_chiclet<'a>(thread_id: &'a str, tags: &'a [String]) -> Node<Ms
|
||||
a![
|
||||
C![&tw_classes::TAG_X],
|
||||
span![i![C!["fa-solid", "fa-xmark"]]],
|
||||
ev(Ev::Click, move |_| Msg::RemoveTag(thread_id, rm_tag))
|
||||
ev(Ev::Click, move |_| Msg::RemoveTagAndGoToSearch(thread_id, rm_tag))
|
||||
]
|
||||
]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user