Skip to content

Commit bb7c0f7

Browse files
author
ComputelessComputer
committed
add Apple Calendar integration to calendar tab
1 parent 5ca456f commit bb7c0f7

2 files changed

Lines changed: 211 additions & 37 deletions

File tree

src/calendar.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use std::process::Command;
2+
3+
#[derive(Debug, Clone)]
4+
pub struct CalendarEvent {
5+
pub id: String,
6+
pub title: String,
7+
pub start_date: String,
8+
pub end_date: String,
9+
pub calendar_name: String,
10+
pub location: Option<String>,
11+
pub notes: Option<String>,
12+
pub all_day: bool,
13+
}
14+
15+
pub fn get_upcoming_events(days: u32) -> Result<Vec<CalendarEvent>, String> {
16+
let script = format!(
17+
r#"
18+
set today to current date
19+
set futureDate to today + ({days} * days)
20+
set output to ""
21+
tell application "Calendar"
22+
repeat with cal in calendars
23+
set calName to name of cal
24+
set evts to (every event of cal whose start date >= today and start date <= futureDate)
25+
repeat with evt in evts
26+
set evtId to uid of evt
27+
set evtTitle to summary of evt
28+
set evtStart to start date of evt as string
29+
set evtEnd to end date of evt as string
30+
set evtAllDay to allday event of evt
31+
set allDayStr to "false"
32+
if evtAllDay then set allDayStr to "true"
33+
set evtLocation to ""
34+
try
35+
set evtLocation to location of evt
36+
end try
37+
set evtNotes to ""
38+
try
39+
set evtNotes to description of evt
40+
end try
41+
if evtNotes is missing value then set evtNotes to ""
42+
if evtLocation is missing value then set evtLocation to ""
43+
set output to output & evtId & " " & evtTitle & " " & evtStart & " " & evtEnd & " " & calName & " " & allDayStr & " " & evtLocation & " " & evtNotes & linefeed
44+
end repeat
45+
end repeat
46+
end tell
47+
return output
48+
"#
49+
);
50+
51+
let output = Command::new("osascript")
52+
.arg("-e")
53+
.arg(&script)
54+
.output()
55+
.map_err(|e| format!("Failed to run osascript: {e}"))?;
56+
57+
if !output.status.success() {
58+
let stderr = String::from_utf8_lossy(&output.stderr);
59+
return Err(format!("osascript (Calendar) failed: {stderr}"));
60+
}
61+
62+
let stdout = String::from_utf8_lossy(&output.stdout);
63+
let mut events = Vec::new();
64+
65+
for line in stdout.lines() {
66+
if line.trim().is_empty() {
67+
continue;
68+
}
69+
let parts: Vec<&str> = line.splitn(8, '\t').collect();
70+
if parts.len() < 6 {
71+
continue;
72+
}
73+
events.push(CalendarEvent {
74+
id: parts[0].to_string(),
75+
title: parts[1].to_string(),
76+
start_date: parts[2].to_string(),
77+
end_date: parts[3].to_string(),
78+
calendar_name: parts[4].to_string(),
79+
all_day: parts[5] == "true",
80+
location: parts
81+
.get(6)
82+
.filter(|s| !s.is_empty())
83+
.map(|s| s.to_string()),
84+
notes: parts
85+
.get(7)
86+
.filter(|s| !s.is_empty())
87+
.map(|s| s.to_string()),
88+
});
89+
}
90+
91+
Ok(events)
92+
}

src/main.rs

Lines changed: 119 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod ai;
2+
mod calendar;
23
mod cli;
34
mod llm;
45
mod mail;
@@ -279,6 +280,10 @@ struct App {
279280
suggestions_rx: Option<mpsc::Receiver<EmailEvent>>,
280281
task_email_map: std::collections::HashMap<Uuid, String>,
281282

283+
calendar_events: Vec<calendar::CalendarEvent>,
284+
calendar_loading: bool,
285+
calendar_rx: Option<mpsc::Receiver<Vec<calendar::CalendarEvent>>>,
286+
282287
esc_count: u8,
283288
esc_last: Instant,
284289
}
@@ -301,6 +306,19 @@ impl Drop for TerminalGuard {
301306
}
302307
}
303308

309+
fn spawn_calendar_fetch() -> mpsc::Receiver<Vec<calendar::CalendarEvent>> {
310+
let (tx, rx) = mpsc::channel();
311+
std::thread::spawn(move || match calendar::get_upcoming_events(14) {
312+
Ok(events) => {
313+
let _ = tx.send(events);
314+
}
315+
Err(_) => {
316+
let _ = tx.send(Vec::new());
317+
}
318+
});
319+
rx
320+
}
321+
304322
fn spawn_email_poller(settings: AiSettings) -> mpsc::Receiver<EmailEvent> {
305323
let (tx, rx) = mpsc::channel();
306324
std::thread::spawn(move || {
@@ -509,6 +527,9 @@ fn main() -> io::Result<()> {
509527
suggestions_selected: 0,
510528
suggestions_rx: None,
511529
task_email_map: std::collections::HashMap::new(),
530+
calendar_events: Vec::new(),
531+
calendar_loading: false,
532+
calendar_rx: None,
512533
esc_count: 0,
513534
esc_last: Instant::now(),
514535
};
@@ -545,6 +566,10 @@ fn run_app(stdout: &mut Stdout, app: &mut App) -> io::Result<()> {
545566
needs_redraw = true;
546567
}
547568

569+
if poll_calendar(app) {
570+
needs_redraw = true;
571+
}
572+
548573
if archive_check.elapsed() >= Duration::from_secs(60) {
549574
if auto_archive_tasks(&mut app.tasks) {
550575
persist(app);
@@ -792,6 +817,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> io::Result<bool> {
792817
app.tab = Tab::Calendar;
793818
app.focus = Focus::Board;
794819
app.status = None;
820+
if !app.calendar_loading && app.calendar_events.is_empty() {
821+
app.calendar_loading = true;
822+
app.calendar_rx = Some(spawn_calendar_fetch());
823+
}
795824
return Ok(false);
796825
}
797826
KeyCode::Char('3') => {
@@ -873,6 +902,10 @@ fn handle_tabs_key(app: &mut App, key: KeyEvent) -> io::Result<bool> {
873902
app.tab = Tab::Calendar;
874903
app.focus = Focus::Board;
875904
app.status = None;
905+
if !app.calendar_loading && app.calendar_events.is_empty() {
906+
app.calendar_loading = true;
907+
app.calendar_rx = Some(spawn_calendar_fetch());
908+
}
876909
}
877910
KeyCode::Char('3') => {
878911
app.tab = Tab::Default;
@@ -3986,6 +4019,18 @@ fn poll_suggestions(app: &mut App) -> bool {
39864019
has_new
39874020
}
39884021

4022+
fn poll_calendar(app: &mut App) -> bool {
4023+
if let Some(rx) = &app.calendar_rx {
4024+
if let Ok(events) = rx.try_recv() {
4025+
app.calendar_events = events;
4026+
app.calendar_loading = false;
4027+
app.calendar_rx = None;
4028+
return true;
4029+
}
4030+
}
4031+
false
4032+
}
4033+
39894034
/// Apply a TaskUpdate to a task, returning true if anything changed.
39904035
fn apply_update(
39914036
task: &mut Task,
@@ -4967,59 +5012,96 @@ fn render_calendar_tab(stdout: &mut Stdout, app: &App, cols: u16, rows: u16) ->
49675012
let events_x = (cal_width + 4) as u16 + cal_x;
49685013
let events_width = content_width.saturating_sub(cal_width + 4);
49695014
if events_width > 10 {
5015+
let mut y_cur = y_start;
5016+
5017+
if !app.calendar_events.is_empty() {
5018+
queue!(
5019+
stdout,
5020+
MoveTo(events_x, y_cur),
5021+
SetAttribute(Attribute::Bold),
5022+
Print(clamp_text("Upcoming events", events_width)),
5023+
SetAttribute(Attribute::Reset)
5024+
)?;
5025+
y_cur += 1;
5026+
for evt in &app.calendar_events {
5027+
if y_cur >= y_start + available_rows as u16 {
5028+
break;
5029+
}
5030+
let entry = format!("{} • {} [{}]", evt.start_date, evt.title, evt.calendar_name);
5031+
queue!(
5032+
stdout,
5033+
MoveTo(events_x, y_cur),
5034+
SetForegroundColor(Color::Magenta),
5035+
Print(clamp_text(&entry, events_width)),
5036+
ResetColor
5037+
)?;
5038+
y_cur += 1;
5039+
}
5040+
y_cur += 1;
5041+
} else if app.calendar_loading {
5042+
queue!(
5043+
stdout,
5044+
MoveTo(events_x, y_cur),
5045+
SetForegroundColor(Color::DarkGrey),
5046+
Print(clamp_text("Loading calendar events...", events_width)),
5047+
ResetColor
5048+
)?;
5049+
y_cur += 2;
5050+
}
5051+
49705052
queue!(
49715053
stdout,
4972-
MoveTo(events_x, y_start),
5054+
MoveTo(events_x, y_cur),
49735055
SetAttribute(Attribute::Bold),
49745056
Print(clamp_text("Tasks with due dates", events_width)),
49755057
SetAttribute(Attribute::Reset)
49765058
)?;
4977-
5059+
y_cur += 1;
49785060
let mut tasks_with_dates: Vec<&model::Task> =
49795061
app.tasks.iter().filter(|t| t.due_date.is_some()).collect();
49805062
tasks_with_dates.sort_by_key(|t| t.due_date);
4981-
4982-
for (i, task) in tasks_with_dates.iter().enumerate() {
4983-
let y_row = y_start + 1 + i as u16;
4984-
if y_row >= y_start + available_rows as u16 {
4985-
break;
4986-
}
4987-
let date_str = task
4988-
.due_date
4989-
.map(|d| d.format("%m/%d").to_string())
4990-
.unwrap_or_default();
4991-
let short_id = task.id.to_string().chars().take(8).collect::<String>();
4992-
let status_icon = match task.progress {
4993-
model::Progress::Done => "\u{2713}",
4994-
model::Progress::InProgress => "\u{25d0}",
4995-
model::Progress::Todo => "\u{25cb}",
4996-
model::Progress::Backlog => "\u{b7}",
4997-
model::Progress::Archived => "\u{2298}",
4998-
};
4999-
let entry = format!("{} {} {} {}", date_str, status_icon, short_id, task.title);
5000-
let status_color = match task.progress {
5001-
model::Progress::Done => Color::DarkGrey,
5002-
model::Progress::InProgress => Color::Yellow,
5003-
model::Progress::Todo => Color::Blue,
5004-
model::Progress::Backlog => Color::DarkGrey,
5005-
model::Progress::Archived => Color::DarkGrey,
5006-
};
5007-
queue!(
5008-
stdout,
5009-
MoveTo(events_x, y_row),
5010-
SetForegroundColor(status_color),
5011-
Print(clamp_text(&entry, events_width)),
5012-
ResetColor
5013-
)?;
5014-
}
50155063
if tasks_with_dates.is_empty() {
50165064
queue!(
50175065
stdout,
5018-
MoveTo(events_x, y_start + 1),
5066+
MoveTo(events_x, y_cur),
50195067
SetForegroundColor(Color::DarkGrey),
50205068
Print(clamp_text("No tasks with due dates", events_width)),
50215069
ResetColor
50225070
)?;
5071+
} else {
5072+
for task in &tasks_with_dates {
5073+
if y_cur >= y_start + available_rows as u16 {
5074+
break;
5075+
}
5076+
let date_str = task
5077+
.due_date
5078+
.map(|d| d.format("%m/%d").to_string())
5079+
.unwrap_or_default();
5080+
let short_id = task.id.to_string().chars().take(8).collect::<String>();
5081+
let status_icon = match task.progress {
5082+
model::Progress::Done => "\u{2713}",
5083+
model::Progress::InProgress => "\u{25d0}",
5084+
model::Progress::Todo => "\u{25cb}",
5085+
model::Progress::Backlog => "\u{b7}",
5086+
model::Progress::Archived => "\u{2298}",
5087+
};
5088+
let entry = format!("{} {} {} {}", date_str, status_icon, short_id, task.title);
5089+
let status_color = match task.progress {
5090+
model::Progress::Done => Color::DarkGrey,
5091+
model::Progress::InProgress => Color::Yellow,
5092+
model::Progress::Todo => Color::Blue,
5093+
model::Progress::Backlog => Color::DarkGrey,
5094+
model::Progress::Archived => Color::DarkGrey,
5095+
};
5096+
queue!(
5097+
stdout,
5098+
MoveTo(events_x, y_cur),
5099+
SetForegroundColor(status_color),
5100+
Print(clamp_text(&entry, events_width)),
5101+
ResetColor
5102+
)?;
5103+
y_cur += 1;
5104+
}
50235105
}
50245106
}
50255107

0 commit comments

Comments
 (0)