Add calendar target selector
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
027ce5189ab5349784cd617150f71dd3c7ece796- Parents
-
816267c - Tree
3abe913
027ce51
027ce5189ab5349784cd617150f71dd3c7ece796816267c
3abe913| Status | File | + | - |
|---|---|---|---|
| M |
README.md
|
6 | 3 |
| M |
src/agenda.rs
|
143 | 2 |
| M |
src/app.rs
|
312 | 10 |
| M |
src/cli.rs
|
9 | 2 |
| M |
src/providers.rs
|
199 | 8 |
| M |
src/tui.rs
|
41 | 23 |
README.mdmodified@@ -170,9 +170,12 @@ daemon does not sync remote calendars itself. | ||
| 170 | 170 | - Left click selects a visible date; double-click a visible date to open day |
| 171 | 171 | view. |
| 172 | 172 | |
| 173 | -Created events are stored locally as JSON and are shown immediately in month, | |
| 174 | -week, and day views. The create/edit modal supports timed events, single-day | |
| 175 | -all-day events, recurrence, location, notes, and multiple reminder offsets. | |
| 173 | +The create/edit modal supports timed events, single-day all-day events, | |
| 174 | +recurrence, location, notes, and multiple reminder offsets. Its `Calendar` | |
| 175 | +field controls where the event is saved; use Left/Right on that field to cycle | |
| 176 | +between local storage and configured editable provider calendars. Local events | |
| 177 | +are stored as JSON, while Microsoft events are written through Graph and then | |
| 178 | +shown immediately from the provider cache. | |
| 176 | 179 | Reminder notifications are delivered by a user-level background service. Use |
| 177 | 180 | `rcal reminders install` to install it, `rcal reminders status` to inspect it, |
| 178 | 181 | and `rcal reminders test` to send a test notification. On macOS, notification |
src/agenda.rsmodified@@ -133,6 +133,64 @@ impl SourceMetadata { | ||
| 133 | 133 | } |
| 134 | 134 | } |
| 135 | 135 | |
| 136 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 137 | +pub struct EventWriteTarget { | |
| 138 | + pub id: EventWriteTargetId, | |
| 139 | + pub label: String, | |
| 140 | +} | |
| 141 | + | |
| 142 | +impl EventWriteTarget { | |
| 143 | + pub fn local() -> Self { | |
| 144 | + Self { | |
| 145 | + id: EventWriteTargetId::Local, | |
| 146 | + label: "Local".to_string(), | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + pub fn microsoft( | |
| 151 | + account_id: impl Into<String>, | |
| 152 | + calendar_id: impl Into<String>, | |
| 153 | + label: impl Into<String>, | |
| 154 | + ) -> Self { | |
| 155 | + Self { | |
| 156 | + id: EventWriteTargetId::Microsoft { | |
| 157 | + account_id: account_id.into(), | |
| 158 | + calendar_id: calendar_id.into(), | |
| 159 | + }, | |
| 160 | + label: label.into(), | |
| 161 | + } | |
| 162 | + } | |
| 163 | +} | |
| 164 | + | |
| 165 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 166 | +pub enum EventWriteTargetId { | |
| 167 | + Local, | |
| 168 | + Microsoft { | |
| 169 | + account_id: String, | |
| 170 | + calendar_id: String, | |
| 171 | + }, | |
| 172 | +} | |
| 173 | + | |
| 174 | +impl EventWriteTargetId { | |
| 175 | + pub const fn is_local(&self) -> bool { | |
| 176 | + matches!(self, Self::Local) | |
| 177 | + } | |
| 178 | + | |
| 179 | + pub fn from_event(event: &Event) -> Option<Self> { | |
| 180 | + if event.is_local() { | |
| 181 | + return Some(Self::Local); | |
| 182 | + } | |
| 183 | + let mut parts = event.source.source_id.splitn(3, ':'); | |
| 184 | + match (parts.next(), parts.next(), parts.next()) { | |
| 185 | + (Some("microsoft"), Some(account_id), Some(calendar_id)) => Some(Self::Microsoft { | |
| 186 | + account_id: account_id.to_string(), | |
| 187 | + calendar_id: calendar_id.to_string(), | |
| 188 | + }), | |
| 189 | + _ => None, | |
| 190 | + } | |
| 191 | + } | |
| 192 | +} | |
| 193 | + | |
| 136 | 194 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 137 | 195 | pub struct Reminder { |
| 138 | 196 | pub minutes_before: u16, |
@@ -592,6 +650,14 @@ pub trait AgendaSource { | ||
| 592 | 650 | |
| 593 | 651 | fn holidays_in(&self, range: DateRange) -> Vec<Holiday>; |
| 594 | 652 | |
| 653 | + fn event_write_targets(&self) -> Vec<EventWriteTarget> { | |
| 654 | + vec![EventWriteTarget::local()] | |
| 655 | + } | |
| 656 | + | |
| 657 | + fn default_event_write_target(&self) -> EventWriteTargetId { | |
| 658 | + EventWriteTargetId::Local | |
| 659 | + } | |
| 660 | + | |
| 595 | 661 | fn local_event_by_id(&self, _id: &str) -> Option<Event> { |
| 596 | 662 | None |
| 597 | 663 | } |
@@ -653,16 +719,37 @@ impl ConfiguredAgendaSource { | ||
| 653 | 719 | } |
| 654 | 720 | |
| 655 | 721 | pub fn create_event(&mut self, draft: CreateEventDraft) -> Result<Event, LocalEventStoreError> { |
| 656 | - if self.create_target == ProviderCreateTarget::Microsoft | |
| 722 | + let target = self.default_event_write_target(); | |
| 723 | + self.create_event_with_target(draft, &target) | |
| 724 | + } | |
| 725 | + | |
| 726 | + pub fn create_event_with_target( | |
| 727 | + &mut self, | |
| 728 | + draft: CreateEventDraft, | |
| 729 | + target: &EventWriteTargetId, | |
| 730 | + ) -> Result<Event, LocalEventStoreError> { | |
| 731 | + if let EventWriteTargetId::Microsoft { .. } = target | |
| 657 | 732 | && let Some(microsoft) = &mut self.microsoft |
| 658 | 733 | { |
| 659 | 734 | let http = ReqwestMicrosoftHttpClient; |
| 660 | 735 | let token_store = KeyringMicrosoftTokenStore; |
| 661 | 736 | return microsoft |
| 662 | - .create_event(draft, &http, &token_store) | |
| 737 | + .create_event_in_target(draft, target, &http, &token_store) | |
| 663 | 738 | .map_err(provider_error); |
| 664 | 739 | } |
| 740 | + if matches!(target, EventWriteTargetId::Microsoft { .. }) { | |
| 741 | + return Err(LocalEventStoreError::Provider { | |
| 742 | + reason: "Microsoft provider is not configured".to_string(), | |
| 743 | + }); | |
| 744 | + } | |
| 665 | 745 | |
| 746 | + self.create_local_event(draft) | |
| 747 | + } | |
| 748 | + | |
| 749 | + fn create_local_event( | |
| 750 | + &mut self, | |
| 751 | + draft: CreateEventDraft, | |
| 752 | + ) -> Result<Event, LocalEventStoreError> { | |
| 666 | 753 | let id = self.next_local_event_id(&draft.title); |
| 667 | 754 | let event = draft |
| 668 | 755 | .into_event(id) |
@@ -684,6 +771,28 @@ impl ConfiguredAgendaSource { | ||
| 684 | 771 | id: &str, |
| 685 | 772 | draft: CreateEventDraft, |
| 686 | 773 | ) -> Result<Event, LocalEventStoreError> { |
| 774 | + let target = self | |
| 775 | + .event_target_for_id(id) | |
| 776 | + .unwrap_or(EventWriteTargetId::Local); | |
| 777 | + self.update_event_with_target(id, draft, &target) | |
| 778 | + } | |
| 779 | + | |
| 780 | + pub fn update_event_with_target( | |
| 781 | + &mut self, | |
| 782 | + id: &str, | |
| 783 | + draft: CreateEventDraft, | |
| 784 | + target: &EventWriteTargetId, | |
| 785 | + ) -> Result<Event, LocalEventStoreError> { | |
| 786 | + let current_target = self.event_target_for_id(id); | |
| 787 | + if current_target | |
| 788 | + .as_ref() | |
| 789 | + .is_some_and(|current| current != target) | |
| 790 | + { | |
| 791 | + let created = self.create_event_with_target(draft, target)?; | |
| 792 | + self.delete_event(id)?; | |
| 793 | + return Ok(created); | |
| 794 | + } | |
| 795 | + | |
| 687 | 796 | if self.events.local_event_by_id(id).is_none() |
| 688 | 797 | && id.starts_with("microsoft:") |
| 689 | 798 | && let Some(microsoft) = &mut self.microsoft |
@@ -944,6 +1053,18 @@ impl ConfiguredAgendaSource { | ||
| 944 | 1053 | Ok(event) |
| 945 | 1054 | } |
| 946 | 1055 | |
| 1056 | + fn event_target_for_id(&self, id: &str) -> Option<EventWriteTargetId> { | |
| 1057 | + self.events | |
| 1058 | + .local_event_by_id(id) | |
| 1059 | + .as_ref() | |
| 1060 | + .and_then(EventWriteTargetId::from_event) | |
| 1061 | + .or_else(|| { | |
| 1062 | + let microsoft = self.microsoft.as_ref()?; | |
| 1063 | + let event = microsoft.agenda_source().editable_event_by_id(id)?; | |
| 1064 | + EventWriteTargetId::from_event(&event) | |
| 1065 | + }) | |
| 1066 | + } | |
| 1067 | + | |
| 947 | 1068 | fn next_local_event_id(&self, title: &str) -> String { |
| 948 | 1069 | let now = SystemTime::now() |
| 949 | 1070 | .duration_since(UNIX_EPOCH) |
@@ -977,6 +1098,26 @@ impl AgendaSource for ConfiguredAgendaSource { | ||
| 977 | 1098 | self.holidays.holidays_in(range) |
| 978 | 1099 | } |
| 979 | 1100 | |
| 1101 | + fn event_write_targets(&self) -> Vec<EventWriteTarget> { | |
| 1102 | + let mut targets = vec![EventWriteTarget::local()]; | |
| 1103 | + if let Some(microsoft) = &self.microsoft { | |
| 1104 | + targets.extend(microsoft.write_targets()); | |
| 1105 | + } | |
| 1106 | + targets | |
| 1107 | + } | |
| 1108 | + | |
| 1109 | + fn default_event_write_target(&self) -> EventWriteTargetId { | |
| 1110 | + if self.create_target == ProviderCreateTarget::Microsoft | |
| 1111 | + && let Some(target) = self | |
| 1112 | + .microsoft | |
| 1113 | + .as_ref() | |
| 1114 | + .and_then(MicrosoftProviderRuntime::default_write_target) | |
| 1115 | + { | |
| 1116 | + return target; | |
| 1117 | + } | |
| 1118 | + EventWriteTargetId::Local | |
| 1119 | + } | |
| 1120 | + | |
| 980 | 1121 | fn local_event_by_id(&self, id: &str) -> Option<Event> { |
| 981 | 1122 | self.events.local_event_by_id(id) |
| 982 | 1123 | } |
src/app.rsmodified@@ -12,8 +12,9 @@ use time::{Month, Time, Weekday}; | ||
| 12 | 12 | use crate::{ |
| 13 | 13 | agenda::{ |
| 14 | 14 | AgendaSource, CreateEventDraft, CreateEventTiming, DayAgenda, Event, EventDateTime, |
| 15 | - EventTiming, OccurrenceAnchor, RecurrenceEnd, RecurrenceFrequency, RecurrenceMonthlyRule, | |
| 16 | - RecurrenceRule, RecurrenceYearlyRule, Reminder, recurrence_ordinal_for_date, | |
| 15 | + EventTiming, EventWriteTarget, EventWriteTargetId, OccurrenceAnchor, RecurrenceEnd, | |
| 16 | + RecurrenceFrequency, RecurrenceMonthlyRule, RecurrenceRule, RecurrenceYearlyRule, Reminder, | |
| 17 | + recurrence_ordinal_for_date, | |
| 17 | 18 | }, |
| 18 | 19 | calendar::{CalendarDate, CalendarMonth, DAYS_PER_WEEK}, |
| 19 | 20 | }; |
@@ -201,7 +202,10 @@ impl AppState { | ||
| 201 | 202 | .unwrap_or(false) |
| 202 | 203 | }) |
| 203 | 204 | { |
| 204 | - self.create_form = Some(CreateEventForm::edit_occurrence(&event)); | |
| 205 | + self.create_form = Some(CreateEventForm::edit_occurrence_with_targets( | |
| 206 | + &event, | |
| 207 | + source.event_write_targets(), | |
| 208 | + )); | |
| 205 | 209 | } |
| 206 | 210 | RecurrenceChoiceInputResult::Continue |
| 207 | 211 | } |
@@ -209,7 +213,10 @@ impl AppState { | ||
| 209 | 213 | let series_id = choice.series_id.clone(); |
| 210 | 214 | self.recurrence_choice = None; |
| 211 | 215 | if let Some(event) = source.editable_event_by_id(&series_id) { |
| 212 | - self.create_form = Some(CreateEventForm::edit(&event)); | |
| 216 | + self.create_form = Some(CreateEventForm::edit_with_targets( | |
| 217 | + &event, | |
| 218 | + source.event_write_targets(), | |
| 219 | + )); | |
| 213 | 220 | } |
| 214 | 221 | RecurrenceChoiceInputResult::Continue |
| 215 | 222 | } |
@@ -415,7 +422,18 @@ impl AppState { | ||
| 415 | 422 | ViewMode::Month => CreateEventContext::EditableDate, |
| 416 | 423 | ViewMode::Day => CreateEventContext::FixedDate, |
| 417 | 424 | }; |
| 418 | - self.create_form = Some(CreateEventForm::new(self.selected_date, context)); | |
| 425 | + let targets = source | |
| 426 | + .map(AgendaSource::event_write_targets) | |
| 427 | + .unwrap_or_else(|| vec![EventWriteTarget::local()]); | |
| 428 | + let selected_target = source | |
| 429 | + .map(AgendaSource::default_event_write_target) | |
| 430 | + .unwrap_or(EventWriteTargetId::Local); | |
| 431 | + self.create_form = Some(CreateEventForm::new_with_targets( | |
| 432 | + self.selected_date, | |
| 433 | + context, | |
| 434 | + targets, | |
| 435 | + selected_target, | |
| 436 | + )); | |
| 419 | 437 | } |
| 420 | 438 | } |
| 421 | 439 | AppAction::OpenDelete if self.view_mode == ViewMode::Day => { |
@@ -522,7 +540,10 @@ impl AppState { | ||
| 522 | 540 | occurrence.anchor, |
| 523 | 541 | )); |
| 524 | 542 | } else { |
| 525 | - self.create_form = Some(CreateEventForm::edit(&event)); | |
| 543 | + self.create_form = Some(CreateEventForm::edit_with_targets( | |
| 544 | + &event, | |
| 545 | + source.event_write_targets(), | |
| 546 | + )); | |
| 526 | 547 | } |
| 527 | 548 | } |
| 528 | 549 | } |
@@ -993,6 +1014,8 @@ pub enum HelpInputResult { | ||
| 993 | 1014 | pub struct CreateEventForm { |
| 994 | 1015 | mode: EventFormMode, |
| 995 | 1016 | context: CreateEventContext, |
| 1017 | + targets: Vec<EventWriteTarget>, | |
| 1018 | + selected_target: usize, | |
| 996 | 1019 | selected_date: CalendarDate, |
| 997 | 1020 | title: String, |
| 998 | 1021 | all_day: bool, |
@@ -1015,11 +1038,55 @@ pub struct CreateEventForm { | ||
| 1015 | 1038 | error: Option<String>, |
| 1016 | 1039 | } |
| 1017 | 1040 | |
| 1041 | +fn normalize_write_targets( | |
| 1042 | + targets: Vec<EventWriteTarget>, | |
| 1043 | + current: Option<EventWriteTarget>, | |
| 1044 | +) -> Vec<EventWriteTarget> { | |
| 1045 | + let mut normalized = Vec::new(); | |
| 1046 | + for target in targets.into_iter().chain(current) { | |
| 1047 | + if !normalized | |
| 1048 | + .iter() | |
| 1049 | + .any(|existing: &EventWriteTarget| existing.id == target.id) | |
| 1050 | + { | |
| 1051 | + normalized.push(target); | |
| 1052 | + } | |
| 1053 | + } | |
| 1054 | + if normalized.is_empty() { | |
| 1055 | + normalized.push(EventWriteTarget::local()); | |
| 1056 | + } | |
| 1057 | + normalized | |
| 1058 | +} | |
| 1059 | + | |
| 1060 | +fn selected_target_index(targets: &[EventWriteTarget], selected: &EventWriteTargetId) -> usize { | |
| 1061 | + targets | |
| 1062 | + .iter() | |
| 1063 | + .position(|target| &target.id == selected) | |
| 1064 | + .unwrap_or_default() | |
| 1065 | +} | |
| 1066 | + | |
| 1018 | 1067 | impl CreateEventForm { |
| 1019 | 1068 | pub fn new(selected_date: CalendarDate, context: CreateEventContext) -> Self { |
| 1069 | + Self::new_with_targets( | |
| 1070 | + selected_date, | |
| 1071 | + context, | |
| 1072 | + vec![EventWriteTarget::local()], | |
| 1073 | + EventWriteTargetId::Local, | |
| 1074 | + ) | |
| 1075 | + } | |
| 1076 | + | |
| 1077 | + pub fn new_with_targets( | |
| 1078 | + selected_date: CalendarDate, | |
| 1079 | + context: CreateEventContext, | |
| 1080 | + targets: Vec<EventWriteTarget>, | |
| 1081 | + selected_target: EventWriteTargetId, | |
| 1082 | + ) -> Self { | |
| 1083 | + let targets = normalize_write_targets(targets, None); | |
| 1084 | + let selected_target = selected_target_index(&targets, &selected_target); | |
| 1020 | 1085 | Self { |
| 1021 | 1086 | mode: EventFormMode::Create, |
| 1022 | 1087 | context, |
| 1088 | + targets, | |
| 1089 | + selected_target, | |
| 1023 | 1090 | selected_date, |
| 1024 | 1091 | title: String::new(), |
| 1025 | 1092 | all_day: false, |
@@ -1044,6 +1111,10 @@ impl CreateEventForm { | ||
| 1044 | 1111 | } |
| 1045 | 1112 | |
| 1046 | 1113 | pub fn edit(event: &Event) -> Self { |
| 1114 | + Self::edit_with_targets(event, vec![EventWriteTarget::local()]) | |
| 1115 | + } | |
| 1116 | + | |
| 1117 | + pub fn edit_with_targets(event: &Event, targets: Vec<EventWriteTarget>) -> Self { | |
| 1047 | 1118 | let (all_day, start_date, start_time, end_date, end_time, selected_date) = |
| 1048 | 1119 | match event.timing { |
| 1049 | 1120 | EventTiming::AllDay { date } => ( |
@@ -1072,12 +1143,26 @@ impl CreateEventForm { | ||
| 1072 | 1143 | reminders[index] = true; |
| 1073 | 1144 | } |
| 1074 | 1145 | } |
| 1146 | + let current_target = EventWriteTargetId::from_event(event); | |
| 1147 | + let targets = normalize_write_targets( | |
| 1148 | + targets, | |
| 1149 | + current_target.as_ref().map(|target| EventWriteTarget { | |
| 1150 | + id: target.clone(), | |
| 1151 | + label: event.source.source_name.clone(), | |
| 1152 | + }), | |
| 1153 | + ); | |
| 1154 | + let selected_target = current_target | |
| 1155 | + .as_ref() | |
| 1156 | + .map(|target| selected_target_index(&targets, target)) | |
| 1157 | + .unwrap_or_default(); | |
| 1075 | 1158 | |
| 1076 | 1159 | let mut form = Self { |
| 1077 | 1160 | mode: EventFormMode::Edit { |
| 1078 | 1161 | event_id: event.id.clone(), |
| 1079 | 1162 | }, |
| 1080 | 1163 | context: CreateEventContext::EditableDate, |
| 1164 | + targets, | |
| 1165 | + selected_target, | |
| 1081 | 1166 | selected_date, |
| 1082 | 1167 | title: event.title.clone(), |
| 1083 | 1168 | all_day, |
@@ -1104,10 +1189,14 @@ impl CreateEventForm { | ||
| 1104 | 1189 | } |
| 1105 | 1190 | |
| 1106 | 1191 | pub fn edit_occurrence(event: &Event) -> Self { |
| 1192 | + Self::edit_occurrence_with_targets(event, vec![EventWriteTarget::local()]) | |
| 1193 | + } | |
| 1194 | + | |
| 1195 | + pub fn edit_occurrence_with_targets(event: &Event, targets: Vec<EventWriteTarget>) -> Self { | |
| 1107 | 1196 | let Some(occurrence) = event.occurrence() else { |
| 1108 | - return Self::edit(event); | |
| 1197 | + return Self::edit_with_targets(event, targets); | |
| 1109 | 1198 | }; |
| 1110 | - let mut form = Self::edit(event); | |
| 1199 | + let mut form = Self::edit_with_targets(event, targets); | |
| 1111 | 1200 | form.mode = EventFormMode::EditOccurrence { |
| 1112 | 1201 | series_id: occurrence.series_id.clone(), |
| 1113 | 1202 | anchor: occurrence.anchor, |
@@ -1158,6 +1247,7 @@ impl CreateEventForm { | ||
| 1158 | 1247 | Ok(draft) => CreateEventInputResult::Submit(Box::new(EventFormSubmission { |
| 1159 | 1248 | mode: self.mode.clone(), |
| 1160 | 1249 | draft, |
| 1250 | + target: self.selected_target_id(), | |
| 1161 | 1251 | })), |
| 1162 | 1252 | Err(err) => { |
| 1163 | 1253 | self.error = Some(err.to_string()); |
@@ -1184,6 +1274,14 @@ impl CreateEventForm { | ||
| 1184 | 1274 | self.focus_next(); |
| 1185 | 1275 | CreateEventInputResult::Continue |
| 1186 | 1276 | } |
| 1277 | + KeyCode::Left => { | |
| 1278 | + self.cycle_focused_field(-1); | |
| 1279 | + CreateEventInputResult::Continue | |
| 1280 | + } | |
| 1281 | + KeyCode::Right => { | |
| 1282 | + self.cycle_focused_field(1); | |
| 1283 | + CreateEventInputResult::Continue | |
| 1284 | + } | |
| 1187 | 1285 | KeyCode::Backspace => { |
| 1188 | 1286 | self.edit_text_field(|value| { |
| 1189 | 1287 | value.pop(); |
@@ -1378,7 +1476,11 @@ impl CreateEventForm { | ||
| 1378 | 1476 | } |
| 1379 | 1477 | |
| 1380 | 1478 | fn visible_fields(&self) -> Vec<CreateEventField> { |
| 1381 | - let mut fields = vec![CreateEventField::Title, CreateEventField::AllDay]; | |
| 1479 | + let mut fields = vec![CreateEventField::Title]; | |
| 1480 | + if !matches!(self.mode, EventFormMode::EditOccurrence { .. }) { | |
| 1481 | + fields.push(CreateEventField::Calendar); | |
| 1482 | + } | |
| 1483 | + fields.push(CreateEventField::AllDay); | |
| 1382 | 1484 | if self.context == CreateEventContext::EditableDate { |
| 1383 | 1485 | fields.push(CreateEventField::StartDate); |
| 1384 | 1486 | } |
@@ -1418,6 +1520,7 @@ impl CreateEventForm { | ||
| 1418 | 1520 | fn field_value(&self, field: CreateEventField) -> String { |
| 1419 | 1521 | match field { |
| 1420 | 1522 | CreateEventField::Title => self.title.clone(), |
| 1523 | + CreateEventField::Calendar => self.targets[self.selected_target].label.clone(), | |
| 1421 | 1524 | CreateEventField::AllDay => checkbox(self.all_day).to_string(), |
| 1422 | 1525 | CreateEventField::StartDate => self.start_date.clone(), |
| 1423 | 1526 | CreateEventField::StartTime => self.start_time.clone(), |
@@ -1469,6 +1572,7 @@ impl CreateEventForm { | ||
| 1469 | 1572 | fn activate_focused_field(&mut self) { |
| 1470 | 1573 | match self.focused_field() { |
| 1471 | 1574 | CreateEventField::AllDay => self.all_day = !self.all_day, |
| 1575 | + CreateEventField::Calendar => self.cycle_target(1), | |
| 1472 | 1576 | CreateEventField::Reminder(index) => self.reminders[index] = !self.reminders[index], |
| 1473 | 1577 | CreateEventField::Notes => self.notes.push('\n'), |
| 1474 | 1578 | CreateEventField::Repeat => self.repeat = self.repeat.next(), |
@@ -1483,6 +1587,61 @@ impl CreateEventForm { | ||
| 1483 | 1587 | self.error = None; |
| 1484 | 1588 | } |
| 1485 | 1589 | |
| 1590 | + fn cycle_focused_field(&mut self, delta: i32) { | |
| 1591 | + match self.focused_field() { | |
| 1592 | + CreateEventField::Calendar => self.cycle_target(delta), | |
| 1593 | + CreateEventField::Repeat => { | |
| 1594 | + self.repeat = if delta < 0 { | |
| 1595 | + self.repeat.previous() | |
| 1596 | + } else { | |
| 1597 | + self.repeat.next() | |
| 1598 | + }; | |
| 1599 | + } | |
| 1600 | + CreateEventField::MonthlyMode => { | |
| 1601 | + self.monthly_mode = if delta < 0 { | |
| 1602 | + self.monthly_mode.previous() | |
| 1603 | + } else { | |
| 1604 | + self.monthly_mode.next() | |
| 1605 | + }; | |
| 1606 | + } | |
| 1607 | + CreateEventField::YearlyMode => { | |
| 1608 | + self.yearly_mode = if delta < 0 { | |
| 1609 | + self.yearly_mode.previous() | |
| 1610 | + } else { | |
| 1611 | + self.yearly_mode.next() | |
| 1612 | + }; | |
| 1613 | + } | |
| 1614 | + CreateEventField::RecurrenceEnd => { | |
| 1615 | + self.recurrence_end = if delta < 0 { | |
| 1616 | + self.recurrence_end.previous() | |
| 1617 | + } else { | |
| 1618 | + self.recurrence_end.next() | |
| 1619 | + }; | |
| 1620 | + } | |
| 1621 | + _ => {} | |
| 1622 | + } | |
| 1623 | + self.error = None; | |
| 1624 | + } | |
| 1625 | + | |
| 1626 | + fn cycle_target(&mut self, delta: i32) { | |
| 1627 | + let len = self.targets.len(); | |
| 1628 | + if len <= 1 { | |
| 1629 | + return; | |
| 1630 | + } | |
| 1631 | + self.selected_target = if delta < 0 { | |
| 1632 | + (self.selected_target + len - 1) % len | |
| 1633 | + } else { | |
| 1634 | + (self.selected_target + 1) % len | |
| 1635 | + }; | |
| 1636 | + } | |
| 1637 | + | |
| 1638 | + fn selected_target_id(&self) -> EventWriteTargetId { | |
| 1639 | + self.targets | |
| 1640 | + .get(self.selected_target) | |
| 1641 | + .map(|target| target.id.clone()) | |
| 1642 | + .unwrap_or(EventWriteTargetId::Local) | |
| 1643 | + } | |
| 1644 | + | |
| 1486 | 1645 | fn edit_text_field(&mut self, edit: impl FnOnce(&mut String)) { |
| 1487 | 1646 | let field = self.focused_field(); |
| 1488 | 1647 | let target = match field { |
@@ -1497,6 +1656,7 @@ impl CreateEventForm { | ||
| 1497 | 1656 | CreateEventField::UntilDate => Some(&mut self.recurrence_until_date), |
| 1498 | 1657 | CreateEventField::OccurrenceCount => Some(&mut self.recurrence_count), |
| 1499 | 1658 | CreateEventField::AllDay |
| 1659 | + | CreateEventField::Calendar | |
| 1500 | 1660 | | CreateEventField::Reminder(_) |
| 1501 | 1661 | | CreateEventField::Repeat |
| 1502 | 1662 | | CreateEventField::WeeklyDay(_) |
@@ -1525,12 +1685,14 @@ pub enum CreateEventFormRowKind { | ||
| 1525 | 1685 | Text, |
| 1526 | 1686 | Multiline, |
| 1527 | 1687 | Toggle, |
| 1688 | + Selector, | |
| 1528 | 1689 | } |
| 1529 | 1690 | |
| 1530 | 1691 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 1531 | 1692 | pub struct EventFormSubmission { |
| 1532 | 1693 | pub mode: EventFormMode, |
| 1533 | 1694 | pub draft: CreateEventDraft, |
| 1695 | + pub target: EventWriteTargetId, | |
| 1534 | 1696 | } |
| 1535 | 1697 | |
| 1536 | 1698 | #[derive(Debug, Clone, PartialEq, Eq)] |
@@ -1626,6 +1788,16 @@ impl RepeatFrequency { | ||
| 1626 | 1788 | } |
| 1627 | 1789 | } |
| 1628 | 1790 | |
| 1791 | + const fn previous(self) -> Self { | |
| 1792 | + match self { | |
| 1793 | + Self::None => Self::Yearly, | |
| 1794 | + Self::Daily => Self::None, | |
| 1795 | + Self::Weekly => Self::Daily, | |
| 1796 | + Self::Monthly => Self::Weekly, | |
| 1797 | + Self::Yearly => Self::Monthly, | |
| 1798 | + } | |
| 1799 | + } | |
| 1800 | + | |
| 1629 | 1801 | const fn frequency(self) -> Option<RecurrenceFrequency> { |
| 1630 | 1802 | match self { |
| 1631 | 1803 | Self::None => None, |
@@ -1666,6 +1838,10 @@ impl RecurrenceMonthlyFormMode { | ||
| 1666 | 1838 | Self::WeekdayOrdinal => Self::DayOfMonth, |
| 1667 | 1839 | } |
| 1668 | 1840 | } |
| 1841 | + | |
| 1842 | + const fn previous(self) -> Self { | |
| 1843 | + self.next() | |
| 1844 | + } | |
| 1669 | 1845 | } |
| 1670 | 1846 | |
| 1671 | 1847 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
@@ -1688,6 +1864,10 @@ impl RecurrenceYearlyFormMode { | ||
| 1688 | 1864 | Self::WeekdayOrdinal => Self::Date, |
| 1689 | 1865 | } |
| 1690 | 1866 | } |
| 1867 | + | |
| 1868 | + const fn previous(self) -> Self { | |
| 1869 | + self.next() | |
| 1870 | + } | |
| 1691 | 1871 | } |
| 1692 | 1872 | |
| 1693 | 1873 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
@@ -1713,11 +1893,20 @@ impl RecurrenceEndFormMode { | ||
| 1713 | 1893 | Self::Count => Self::Never, |
| 1714 | 1894 | } |
| 1715 | 1895 | } |
| 1896 | + | |
| 1897 | + const fn previous(self) -> Self { | |
| 1898 | + match self { | |
| 1899 | + Self::Never => Self::Count, | |
| 1900 | + Self::Until => Self::Never, | |
| 1901 | + Self::Count => Self::Until, | |
| 1902 | + } | |
| 1903 | + } | |
| 1716 | 1904 | } |
| 1717 | 1905 | |
| 1718 | 1906 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1719 | 1907 | enum CreateEventField { |
| 1720 | 1908 | Title, |
| 1909 | + Calendar, | |
| 1721 | 1910 | AllDay, |
| 1722 | 1911 | StartDate, |
| 1723 | 1912 | StartTime, |
@@ -1740,6 +1929,7 @@ impl CreateEventField { | ||
| 1740 | 1929 | const fn label(self) -> &'static str { |
| 1741 | 1930 | match self { |
| 1742 | 1931 | Self::Title => "Title", |
| 1932 | + Self::Calendar => "Calendar", | |
| 1743 | 1933 | Self::AllDay => "All day", |
| 1744 | 1934 | Self::StartDate => "Start date", |
| 1745 | 1935 | Self::StartTime => "Start time", |
@@ -1763,6 +1953,11 @@ impl CreateEventField { | ||
| 1763 | 1953 | match self { |
| 1764 | 1954 | Self::Notes => CreateEventFormRowKind::Multiline, |
| 1765 | 1955 | Self::AllDay | Self::Reminder(_) | Self::WeeklyDay(_) => CreateEventFormRowKind::Toggle, |
| 1956 | + Self::Calendar | |
| 1957 | + | Self::Repeat | |
| 1958 | + | Self::MonthlyMode | |
| 1959 | + | Self::YearlyMode | |
| 1960 | + | Self::RecurrenceEnd => CreateEventFormRowKind::Selector, | |
| 1766 | 1961 | _ => CreateEventFormRowKind::Text, |
| 1767 | 1962 | } |
| 1768 | 1963 | } |
@@ -2678,7 +2873,9 @@ mod tests { | ||
| 2678 | 2873 | use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; |
| 2679 | 2874 | use time::{Month, Time}; |
| 2680 | 2875 | |
| 2681 | - use crate::agenda::{Holiday, InMemoryAgendaSource, RecurrenceOrdinal, SourceMetadata}; | |
| 2876 | + use crate::agenda::{ | |
| 2877 | + DateRange, Holiday, InMemoryAgendaSource, RecurrenceOrdinal, SourceMetadata, | |
| 2878 | + }; | |
| 2682 | 2879 | |
| 2683 | 2880 | fn date(year: i32, month: Month, day: u8) -> CalendarDate { |
| 2684 | 2881 | CalendarDate::from_ymd(year, month, day).expect("valid test date") |
@@ -2758,6 +2955,37 @@ mod tests { | ||
| 2758 | 2955 | } |
| 2759 | 2956 | } |
| 2760 | 2957 | |
| 2958 | + struct WriteTargetSource { | |
| 2959 | + targets: Vec<EventWriteTarget>, | |
| 2960 | + default: EventWriteTargetId, | |
| 2961 | + } | |
| 2962 | + | |
| 2963 | + impl AgendaSource for WriteTargetSource { | |
| 2964 | + fn events_intersecting(&self, _range: DateRange) -> Vec<Event> { | |
| 2965 | + Vec::new() | |
| 2966 | + } | |
| 2967 | + | |
| 2968 | + fn holidays_in(&self, _range: DateRange) -> Vec<Holiday> { | |
| 2969 | + Vec::new() | |
| 2970 | + } | |
| 2971 | + | |
| 2972 | + fn event_write_targets(&self) -> Vec<EventWriteTarget> { | |
| 2973 | + self.targets.clone() | |
| 2974 | + } | |
| 2975 | + | |
| 2976 | + fn default_event_write_target(&self) -> EventWriteTargetId { | |
| 2977 | + self.default.clone() | |
| 2978 | + } | |
| 2979 | + } | |
| 2980 | + | |
| 2981 | + fn form_value(form: &CreateEventForm, label: &str) -> String { | |
| 2982 | + form.rows() | |
| 2983 | + .into_iter() | |
| 2984 | + .find(|row| row.label == label) | |
| 2985 | + .map(|row| row.value) | |
| 2986 | + .expect("form row exists") | |
| 2987 | + } | |
| 2988 | + | |
| 2761 | 2989 | #[test] |
| 2762 | 2990 | fn configured_keybindings_replace_default_normal_mode_commands() { |
| 2763 | 2991 | let bindings = KeyBindings::with_overrides(KeyBindingOverrides { |
@@ -3448,6 +3676,24 @@ mod tests { | ||
| 3448 | 3676 | ); |
| 3449 | 3677 | } |
| 3450 | 3678 | |
| 3679 | + #[test] | |
| 3680 | + fn plus_opens_create_form_with_agenda_source_default_calendar_target() { | |
| 3681 | + let day = date(2026, Month::April, 23); | |
| 3682 | + let target = EventWriteTarget::microsoft("work", "cal", "Microsoft work: Calendar"); | |
| 3683 | + let source = WriteTargetSource { | |
| 3684 | + targets: vec![EventWriteTarget::local(), target.clone()], | |
| 3685 | + default: target.id.clone(), | |
| 3686 | + }; | |
| 3687 | + let mut app = AppState::new(day); | |
| 3688 | + let mut input = KeyboardInput::default(); | |
| 3689 | + | |
| 3690 | + app.apply_with_agenda_source(input.translate(char_key('+')), &source); | |
| 3691 | + | |
| 3692 | + let form = app.create_form().expect("form opens"); | |
| 3693 | + assert_eq!(form.context(), CreateEventContext::EditableDate); | |
| 3694 | + assert_eq!(form_value(form, "Calendar"), "Microsoft work: Calendar"); | |
| 3695 | + } | |
| 3696 | + | |
| 3451 | 3697 | #[test] |
| 3452 | 3698 | fn question_mark_opens_help_and_esc_closes_it() { |
| 3453 | 3699 | let day = date(2026, Month::April, 23); |
@@ -3524,6 +3770,43 @@ mod tests { | ||
| 3524 | 3770 | assert_eq!(app.selected_date(), day); |
| 3525 | 3771 | } |
| 3526 | 3772 | |
| 3773 | + #[test] | |
| 3774 | + fn create_form_calendar_selector_cycles_and_submits_target() { | |
| 3775 | + let day = date(2026, Month::April, 23); | |
| 3776 | + let calendar = EventWriteTarget::microsoft("work", "cal", "Microsoft work: Calendar"); | |
| 3777 | + let personal = EventWriteTarget::microsoft("work", "personal", "Microsoft work: Personal"); | |
| 3778 | + let mut form = CreateEventForm::new_with_targets( | |
| 3779 | + day, | |
| 3780 | + CreateEventContext::EditableDate, | |
| 3781 | + vec![ | |
| 3782 | + EventWriteTarget::local(), | |
| 3783 | + calendar.clone(), | |
| 3784 | + personal.clone(), | |
| 3785 | + ], | |
| 3786 | + calendar.id.clone(), | |
| 3787 | + ); | |
| 3788 | + | |
| 3789 | + form.title = "Planning".to_string(); | |
| 3790 | + let _ = form.handle_key(key(KeyCode::Tab)); | |
| 3791 | + assert_eq!(form_value(&form, "Calendar"), "Microsoft work: Calendar"); | |
| 3792 | + | |
| 3793 | + let _ = form.handle_key(key(KeyCode::Right)); | |
| 3794 | + assert_eq!(form_value(&form, "Calendar"), "Microsoft work: Personal"); | |
| 3795 | + let _ = form.handle_key(key(KeyCode::Left)); | |
| 3796 | + assert_eq!(form_value(&form, "Calendar"), "Microsoft work: Calendar"); | |
| 3797 | + let _ = form.handle_key(key(KeyCode::Left)); | |
| 3798 | + assert_eq!(form_value(&form, "Calendar"), "Local"); | |
| 3799 | + let _ = form.handle_key(key(KeyCode::Right)); | |
| 3800 | + let _ = form.handle_key(key(KeyCode::Right)); | |
| 3801 | + | |
| 3802 | + let result = form.handle_key(ctrl_char_key('s')); | |
| 3803 | + | |
| 3804 | + let CreateEventInputResult::Submit(submission) = result else { | |
| 3805 | + panic!("form should submit"); | |
| 3806 | + }; | |
| 3807 | + assert_eq!(submission.target, personal.id); | |
| 3808 | + } | |
| 3809 | + | |
| 3527 | 3810 | #[test] |
| 3528 | 3811 | fn create_form_validates_required_title() { |
| 3529 | 3812 | let mut form = CreateEventForm::new( |
@@ -3568,6 +3851,25 @@ mod tests { | ||
| 3568 | 3851 | assert!(form.reminders[4]); |
| 3569 | 3852 | } |
| 3570 | 3853 | |
| 3854 | + #[test] | |
| 3855 | + fn edit_form_preloads_provider_calendar_target() { | |
| 3856 | + let day = date(2026, Month::April, 23); | |
| 3857 | + let target = EventWriteTarget::microsoft("work", "cal", "Microsoft work: Calendar"); | |
| 3858 | + let event = Event::timed( | |
| 3859 | + "microsoft:work:cal:remote-1", | |
| 3860 | + "Sync", | |
| 3861 | + at(day, 9, 0), | |
| 3862 | + at(day, 9, 30), | |
| 3863 | + SourceMetadata::new("microsoft:work:cal", "Microsoft work: Calendar"), | |
| 3864 | + ) | |
| 3865 | + .expect("valid provider event"); | |
| 3866 | + | |
| 3867 | + let form = | |
| 3868 | + CreateEventForm::edit_with_targets(&event, vec![EventWriteTarget::local(), target]); | |
| 3869 | + | |
| 3870 | + assert_eq!(form_value(&form, "Calendar"), "Microsoft work: Calendar"); | |
| 3871 | + } | |
| 3872 | + | |
| 3571 | 3873 | #[test] |
| 3572 | 3874 | fn edit_form_preloads_all_day_event_and_can_switch_to_timed() { |
| 3573 | 3875 | let day = date(2026, Month::April, 23); |
src/cli.rsmodified@@ -1631,7 +1631,10 @@ where | ||
| 1631 | 1631 | let submission = *submission; |
| 1632 | 1632 | match submission.mode { |
| 1633 | 1633 | EventFormMode::Create => { |
| 1634 | - match agenda_source.create_event(submission.draft) { | |
| 1634 | + match agenda_source.create_event_with_target( | |
| 1635 | + submission.draft, | |
| 1636 | + &submission.target, | |
| 1637 | + ) { | |
| 1635 | 1638 | Ok(_) => { |
| 1636 | 1639 | app.close_create_form(); |
| 1637 | 1640 | app.reconcile_day_event_selection(&agenda_source); |
@@ -1640,7 +1643,11 @@ where | ||
| 1640 | 1643 | } |
| 1641 | 1644 | } |
| 1642 | 1645 | EventFormMode::Edit { event_id } => { |
| 1643 | - match agenda_source.update_event(&event_id, submission.draft) { | |
| 1646 | + match agenda_source.update_event_with_target( | |
| 1647 | + &event_id, | |
| 1648 | + submission.draft, | |
| 1649 | + &submission.target, | |
| 1650 | + ) { | |
| 1644 | 1651 | Ok(_) => { |
| 1645 | 1652 | app.close_create_form(); |
| 1646 | 1653 | app.reconcile_day_event_selection(&agenda_source); |
src/providers.rsmodified@@ -19,9 +19,10 @@ use time::{Month, Time, Weekday}; | ||
| 19 | 19 | use crate::{ |
| 20 | 20 | agenda::{ |
| 21 | 21 | AgendaError, AgendaSource, CreateEventDraft, CreateEventTiming, DateRange, Event, |
| 22 | - EventDateTime, Holiday, InMemoryAgendaSource, OccurrenceAnchor, OccurrenceMetadata, | |
| 23 | - RecurrenceEnd, RecurrenceFrequency, RecurrenceMonthlyRule, RecurrenceOrdinal, | |
| 24 | - RecurrenceRule, RecurrenceYearlyRule, Reminder, SourceMetadata, | |
| 22 | + EventDateTime, EventWriteTarget, EventWriteTargetId, Holiday, InMemoryAgendaSource, | |
| 23 | + OccurrenceAnchor, OccurrenceMetadata, RecurrenceEnd, RecurrenceFrequency, | |
| 24 | + RecurrenceMonthlyRule, RecurrenceOrdinal, RecurrenceRule, RecurrenceYearlyRule, Reminder, | |
| 25 | + SourceMetadata, | |
| 25 | 26 | }, |
| 26 | 27 | calendar::CalendarDate, |
| 27 | 28 | }; |
@@ -326,6 +327,46 @@ impl MicrosoftProviderRuntime { | ||
| 326 | 327 | } |
| 327 | 328 | } |
| 328 | 329 | |
| 330 | + pub fn write_targets(&self) -> Vec<EventWriteTarget> { | |
| 331 | + self.config | |
| 332 | + .accounts | |
| 333 | + .iter() | |
| 334 | + .flat_map(|account| { | |
| 335 | + account.calendars.iter().filter_map(|calendar_id| { | |
| 336 | + let record = self.cache.calendar_record(&account.id, calendar_id); | |
| 337 | + if record.as_ref().is_some_and(|calendar| !calendar.can_edit) { | |
| 338 | + return None; | |
| 339 | + } | |
| 340 | + let label = record | |
| 341 | + .as_ref() | |
| 342 | + .map(|calendar| calendar.name.as_str()) | |
| 343 | + .filter(|name| !name.trim().is_empty()) | |
| 344 | + .map(|name| format!("Microsoft {}: {name}", account.id)) | |
| 345 | + .unwrap_or_else(|| { | |
| 346 | + format!( | |
| 347 | + "Microsoft {}: {}", | |
| 348 | + account.id, | |
| 349 | + short_calendar_label(calendar_id) | |
| 350 | + ) | |
| 351 | + }); | |
| 352 | + Some(EventWriteTarget::microsoft( | |
| 353 | + account.id.clone(), | |
| 354 | + calendar_id.clone(), | |
| 355 | + label, | |
| 356 | + )) | |
| 357 | + }) | |
| 358 | + }) | |
| 359 | + .collect() | |
| 360 | + } | |
| 361 | + | |
| 362 | + pub fn default_write_target(&self) -> Option<EventWriteTargetId> { | |
| 363 | + let (account, calendar_id) = self.config.default_calendar()?; | |
| 364 | + Some(EventWriteTargetId::Microsoft { | |
| 365 | + account_id: account.id.clone(), | |
| 366 | + calendar_id: calendar_id.to_string(), | |
| 367 | + }) | |
| 368 | + } | |
| 369 | + | |
| 329 | 370 | pub fn status(&self, token_store: &dyn MicrosoftTokenStore) -> MicrosoftProviderStatus { |
| 330 | 371 | let accounts = self |
| 331 | 372 | .config |
@@ -400,11 +441,44 @@ impl MicrosoftProviderRuntime { | ||
| 400 | 441 | http: &dyn MicrosoftHttpClient, |
| 401 | 442 | token_store: &dyn MicrosoftTokenStore, |
| 402 | 443 | ) -> Result<Event, ProviderError> { |
| 403 | - let (account, calendar_id) = self.config.default_calendar().ok_or_else(|| { | |
| 444 | + let target = self.default_write_target().ok_or_else(|| { | |
| 404 | 445 | ProviderError::Config("no Microsoft default calendar configured".to_string()) |
| 405 | 446 | })?; |
| 406 | - let account = account.clone(); | |
| 407 | - let calendar_id = calendar_id.to_string(); | |
| 447 | + self.create_event_in_target(draft, &target, http, token_store) | |
| 448 | + } | |
| 449 | + | |
| 450 | + pub fn create_event_in_target( | |
| 451 | + &mut self, | |
| 452 | + draft: CreateEventDraft, | |
| 453 | + target: &EventWriteTargetId, | |
| 454 | + http: &dyn MicrosoftHttpClient, | |
| 455 | + token_store: &dyn MicrosoftTokenStore, | |
| 456 | + ) -> Result<Event, ProviderError> { | |
| 457 | + let EventWriteTargetId::Microsoft { | |
| 458 | + account_id, | |
| 459 | + calendar_id, | |
| 460 | + } = target | |
| 461 | + else { | |
| 462 | + return Err(ProviderError::Config( | |
| 463 | + "Microsoft provider requires a Microsoft calendar target".to_string(), | |
| 464 | + )); | |
| 465 | + }; | |
| 466 | + let account = self | |
| 467 | + .config | |
| 468 | + .account(account_id) | |
| 469 | + .ok_or_else(|| { | |
| 470 | + ProviderError::Config(format!("account '{account_id}' is not configured")) | |
| 471 | + })? | |
| 472 | + .clone(); | |
| 473 | + if !account | |
| 474 | + .calendars | |
| 475 | + .iter() | |
| 476 | + .any(|calendar| calendar == calendar_id) | |
| 477 | + { | |
| 478 | + return Err(ProviderError::Config(format!( | |
| 479 | + "calendar '{calendar_id}' is not configured for account '{account_id}'" | |
| 480 | + ))); | |
| 481 | + } | |
| 408 | 482 | let token = access_token(&account, http, token_store)?; |
| 409 | 483 | let body = graph_event_payload(&draft, false)?; |
| 410 | 484 | let response = graph_request( |
@@ -412,14 +486,14 @@ impl MicrosoftProviderRuntime { | ||
| 412 | 486 | "POST", |
| 413 | 487 | &format!( |
| 414 | 488 | "{GRAPH_BASE_URL}/me/calendars/{}/events", |
| 415 | - percent_encode(&calendar_id) | |
| 489 | + percent_encode(calendar_id) | |
| 416 | 490 | ), |
| 417 | 491 | &token, |
| 418 | 492 | Some(body.to_string()), |
| 419 | 493 | )?; |
| 420 | 494 | let value = parse_graph_success_json(response)?; |
| 421 | 495 | let calendar = |
| 422 | - fetch_calendar(http, &token, &calendar_id).unwrap_or(MicrosoftCalendarRecord { | |
| 496 | + fetch_calendar(http, &token, calendar_id).unwrap_or(MicrosoftCalendarRecord { | |
| 423 | 497 | id: calendar_id.clone(), |
| 424 | 498 | name: calendar_id.clone(), |
| 425 | 499 | can_edit: true, |
@@ -2331,6 +2405,16 @@ fn microsoft_event_app_id(account_id: &str, calendar_id: &str, graph_id: &str) - | ||
| 2331 | 2405 | format!("microsoft:{account_id}:{calendar_id}:{graph_id}") |
| 2332 | 2406 | } |
| 2333 | 2407 | |
| 2408 | +fn short_calendar_label(calendar_id: &str) -> String { | |
| 2409 | + const MAX: usize = 18; | |
| 2410 | + let label = calendar_id.chars().take(MAX).collect::<String>(); | |
| 2411 | + if calendar_id.chars().count() > MAX { | |
| 2412 | + format!("{label}...") | |
| 2413 | + } else { | |
| 2414 | + label | |
| 2415 | + } | |
| 2416 | +} | |
| 2417 | + | |
| 2334 | 2418 | fn anchor_label(anchor: OccurrenceAnchor) -> String { |
| 2335 | 2419 | match anchor { |
| 2336 | 2420 | OccurrenceAnchor::AllDay { date } => date.to_string(), |
@@ -2632,6 +2716,15 @@ mod tests { | ||
| 2632 | 2716 | } |
| 2633 | 2717 | } |
| 2634 | 2718 | |
| 2719 | + fn calendar_record(id: &str, name: &str, can_edit: bool) -> MicrosoftCalendarRecord { | |
| 2720 | + MicrosoftCalendarRecord { | |
| 2721 | + id: id.to_string(), | |
| 2722 | + name: name.to_string(), | |
| 2723 | + can_edit, | |
| 2724 | + is_default: false, | |
| 2725 | + } | |
| 2726 | + } | |
| 2727 | + | |
| 2635 | 2728 | #[derive(Default)] |
| 2636 | 2729 | struct MemoryTokenStore { |
| 2637 | 2730 | tokens: RefCell<HashMap<String, MicrosoftToken>>, |
@@ -2749,6 +2842,104 @@ mod tests { | ||
| 2749 | 2842 | assert!(event.source.source_id.starts_with("microsoft:work:cal")); |
| 2750 | 2843 | } |
| 2751 | 2844 | |
| 2845 | + #[test] | |
| 2846 | + fn provider_write_targets_use_configured_editable_calendars() { | |
| 2847 | + let cache_file = temp_path("targets/microsoft-cache.json"); | |
| 2848 | + let mut config = provider_config(cache_file); | |
| 2849 | + config.default_calendar = Some("team".to_string()); | |
| 2850 | + config.accounts[0].calendars = vec![ | |
| 2851 | + "cal".to_string(), | |
| 2852 | + "team".to_string(), | |
| 2853 | + "holidays".to_string(), | |
| 2854 | + ]; | |
| 2855 | + let mut runtime = MicrosoftProviderRuntime::load(config).expect("load"); | |
| 2856 | + runtime | |
| 2857 | + .cache | |
| 2858 | + .replace_calendar("work", calendar_record("cal", "Work", true), Vec::new(), 0); | |
| 2859 | + runtime.cache.replace_calendar( | |
| 2860 | + "work", | |
| 2861 | + calendar_record("holidays", "Holidays", false), | |
| 2862 | + Vec::new(), | |
| 2863 | + 0, | |
| 2864 | + ); | |
| 2865 | + | |
| 2866 | + let targets = runtime.write_targets(); | |
| 2867 | + | |
| 2868 | + assert_eq!(targets.len(), 2); | |
| 2869 | + assert_eq!(targets[0].label, "Microsoft work: Work"); | |
| 2870 | + assert_eq!(targets[1].label, "Microsoft work: team"); | |
| 2871 | + assert_eq!( | |
| 2872 | + runtime.default_write_target(), | |
| 2873 | + Some(EventWriteTargetId::Microsoft { | |
| 2874 | + account_id: "work".to_string(), | |
| 2875 | + calendar_id: "team".to_string(), | |
| 2876 | + }) | |
| 2877 | + ); | |
| 2878 | + } | |
| 2879 | + | |
| 2880 | + #[test] | |
| 2881 | + fn create_event_uses_explicit_calendar_target() { | |
| 2882 | + let cache_file = temp_path("create-target/microsoft-cache.json"); | |
| 2883 | + let mut config = provider_config(cache_file.clone()); | |
| 2884 | + config.accounts[0].calendars = vec!["cal".to_string(), "personal".to_string()]; | |
| 2885 | + let store = MemoryTokenStore::with_token("work"); | |
| 2886 | + let http = RecordingHttpClient::new(vec![ | |
| 2887 | + RecordingHttpClient::json( | |
| 2888 | + 201, | |
| 2889 | + json!({ | |
| 2890 | + "id": "evt", | |
| 2891 | + "subject": "Planning", | |
| 2892 | + "type": "singleInstance", | |
| 2893 | + "isAllDay": false, | |
| 2894 | + "start": {"dateTime": "2026-04-23T09:00:00", "timeZone": "UTC"}, | |
| 2895 | + "end": {"dateTime": "2026-04-23T10:00:00", "timeZone": "UTC"} | |
| 2896 | + }), | |
| 2897 | + ), | |
| 2898 | + RecordingHttpClient::json( | |
| 2899 | + 200, | |
| 2900 | + json!({ | |
| 2901 | + "id": "personal", | |
| 2902 | + "name": "Personal", | |
| 2903 | + "canEdit": true, | |
| 2904 | + "isDefaultCalendar": false | |
| 2905 | + }), | |
| 2906 | + ), | |
| 2907 | + ]); | |
| 2908 | + let mut runtime = MicrosoftProviderRuntime::load(config).expect("load"); | |
| 2909 | + let day = date(2026, Month::April, 23); | |
| 2910 | + let draft = CreateEventDraft { | |
| 2911 | + title: "Planning".to_string(), | |
| 2912 | + timing: CreateEventTiming::Timed { | |
| 2913 | + start: at(day, 9, 0), | |
| 2914 | + end: at(day, 10, 0), | |
| 2915 | + }, | |
| 2916 | + location: None, | |
| 2917 | + notes: None, | |
| 2918 | + reminders: Vec::new(), | |
| 2919 | + recurrence: None, | |
| 2920 | + }; | |
| 2921 | + let target = EventWriteTargetId::Microsoft { | |
| 2922 | + account_id: "work".to_string(), | |
| 2923 | + calendar_id: "personal".to_string(), | |
| 2924 | + }; | |
| 2925 | + | |
| 2926 | + let event = runtime | |
| 2927 | + .create_event_in_target(draft, &target, &http, &store) | |
| 2928 | + .expect("create succeeds"); | |
| 2929 | + | |
| 2930 | + let _ = fs::remove_dir_all( | |
| 2931 | + cache_file | |
| 2932 | + .parent() | |
| 2933 | + .and_then(Path::parent) | |
| 2934 | + .expect("test root"), | |
| 2935 | + ); | |
| 2936 | + assert_eq!(event.id, "microsoft:work:personal:evt"); | |
| 2937 | + assert_eq!( | |
| 2938 | + http.requests.borrow()[0].url, | |
| 2939 | + format!("{GRAPH_BASE_URL}/me/calendars/personal/events") | |
| 2940 | + ); | |
| 2941 | + } | |
| 2942 | + | |
| 2752 | 2943 | #[test] |
| 2753 | 2944 | fn graph_all_day_recurring_occurrence_maps_anchor() { |
| 2754 | 2945 | let calendar = MicrosoftCalendarRecord { |
src/tui.rsmodified@@ -1011,7 +1011,9 @@ fn render_create_event_modal( | ||
| 1011 | 1011 | |
| 1012 | 1012 | if value_x < content.right() { |
| 1013 | 1013 | match row.kind { |
| 1014 | - CreateEventFormRowKind::Text | CreateEventFormRowKind::Multiline => { | |
| 1014 | + CreateEventFormRowKind::Text | |
| 1015 | + | CreateEventFormRowKind::Multiline | |
| 1016 | + | CreateEventFormRowKind::Selector => { | |
| 1015 | 1017 | write_padded_left( |
| 1016 | 1018 | buf, |
| 1017 | 1019 | line_y, |
@@ -1042,7 +1044,7 @@ fn render_create_event_modal( | ||
| 1042 | 1044 | footer_y, |
| 1043 | 1045 | content.x, |
| 1044 | 1046 | content.width, |
| 1045 | - "Tab/Up/Down fields | Ctrl-S save | Esc cancel", | |
| 1047 | + create_modal_footer(content.width), | |
| 1046 | 1048 | styles.footer, |
| 1047 | 1049 | ); |
| 1048 | 1050 | } |
@@ -1410,19 +1412,19 @@ fn help_rows(view_mode: ViewMode, keybindings: &KeyBindings) -> Vec<(String, &'s | ||
| 1410 | 1412 | keybindings.display_for(KeyCommand::MoveUp), |
| 1411 | 1413 | keybindings.display_for(KeyCommand::MoveDown) |
| 1412 | 1414 | ), |
| 1413 | - "Select a local event", | |
| 1415 | + "Select an editable event", | |
| 1414 | 1416 | ), |
| 1415 | 1417 | ( |
| 1416 | 1418 | keybindings.display_for(KeyCommand::OpenDayOrEdit), |
| 1417 | - "Edit the selected local event", | |
| 1419 | + "Edit the selected event", | |
| 1418 | 1420 | ), |
| 1419 | 1421 | ( |
| 1420 | 1422 | keybindings.display_for(KeyCommand::CopyEvent), |
| 1421 | - "Copy the selected local event", | |
| 1423 | + "Copy the selected event", | |
| 1422 | 1424 | ), |
| 1423 | 1425 | ( |
| 1424 | 1426 | keybindings.display_for(KeyCommand::DeleteEvent), |
| 1425 | - "Delete the selected local event", | |
| 1427 | + "Delete the selected event", | |
| 1426 | 1428 | ), |
| 1427 | 1429 | ( |
| 1428 | 1430 | keybindings.display_for(KeyCommand::CreateEvent), |
@@ -1474,6 +1476,16 @@ fn create_modal_row_height(kind: CreateEventFormRowKind, value: &str, value_widt | ||
| 1474 | 1476 | u16::try_from(create_modal_value_lines(kind, value, value_width).len()).unwrap_or(u16::MAX) |
| 1475 | 1477 | } |
| 1476 | 1478 | |
| 1479 | +fn create_modal_footer(width: u16) -> &'static str { | |
| 1480 | + let full = "Tab/Up/Down fields | Left/Right change | Ctrl-S save | Esc cancel"; | |
| 1481 | + let compact = "Left/Right | Ctrl-S save | Esc"; | |
| 1482 | + if usize::from(width) >= full.chars().count() { | |
| 1483 | + full | |
| 1484 | + } else { | |
| 1485 | + compact | |
| 1486 | + } | |
| 1487 | +} | |
| 1488 | + | |
| 1477 | 1489 | fn create_modal_value_lines( |
| 1478 | 1490 | kind: CreateEventFormRowKind, |
| 1479 | 1491 | value: &str, |
@@ -1481,7 +1493,9 @@ fn create_modal_value_lines( | ||
| 1481 | 1493 | ) -> Vec<String> { |
| 1482 | 1494 | match kind { |
| 1483 | 1495 | CreateEventFormRowKind::Multiline => wrap_text_lines(value, value_width), |
| 1484 | - CreateEventFormRowKind::Text | CreateEventFormRowKind::Toggle => { | |
| 1496 | + CreateEventFormRowKind::Text | |
| 1497 | + | CreateEventFormRowKind::Toggle | |
| 1498 | + | CreateEventFormRowKind::Selector => { | |
| 1485 | 1499 | vec![value.to_string()] |
| 1486 | 1500 | } |
| 1487 | 1501 | } |
@@ -2834,8 +2848,8 @@ mod tests { | ||
| 2834 | 2848 | let rendered = render_app_to_string(&app, 84, 26); |
| 2835 | 2849 | |
| 2836 | 2850 | assert!(rendered.contains("Day keys")); |
| 2837 | - assert!(rendered.contains("Copy the selected local event")); | |
| 2838 | - assert!(rendered.contains("Delete the selected local event")); | |
| 2851 | + assert!(rendered.contains("Copy the selected event")); | |
| 2852 | + assert!(rendered.contains("Delete the selected event")); | |
| 2839 | 2853 | assert!(rendered.contains("Move to the previous or next day")); |
| 2840 | 2854 | } |
| 2841 | 2855 | |
@@ -2891,6 +2905,7 @@ mod tests { | ||
| 2891 | 2905 | app.apply(AppAction::OpenCreate); |
| 2892 | 2906 | let _ = app.handle_create_key(key(KeyCode::Char('A'))); |
| 2893 | 2907 | let _ = app.handle_create_key(key(KeyCode::Tab)); |
| 2908 | + let _ = app.handle_create_key(key(KeyCode::Tab)); | |
| 2894 | 2909 | let _ = app.handle_create_key(key(KeyCode::Enter)); |
| 2895 | 2910 | |
| 2896 | 2911 | let area = Rect::new(0, 0, 84, 26); |
@@ -2902,20 +2917,22 @@ mod tests { | ||
| 2902 | 2917 | let label_width = 12.min(content.width.saturating_sub(1)); |
| 2903 | 2918 | let value_x = label_x.saturating_add(label_width).saturating_add(1); |
| 2904 | 2919 | |
| 2905 | - assert_styled_text(&buffer, content.x, row_y + 1, ">", Color::White); | |
| 2920 | + assert_styled_text(&buffer, content.x, row_y + 2, ">", Color::White); | |
| 2906 | 2921 | assert_styled_text(&buffer, label_x, row_y, "Title", Color::White); |
| 2907 | - assert_styled_text(&buffer, label_x, row_y + 1, "All day", Color::White); | |
| 2908 | - assert_styled_text(&buffer, label_x, row_y + 2, "Start date", Color::White); | |
| 2922 | + assert_styled_text(&buffer, label_x, row_y + 1, "Calendar", Color::White); | |
| 2923 | + assert_styled_text(&buffer, label_x, row_y + 2, "All day", Color::White); | |
| 2924 | + assert_styled_text(&buffer, label_x, row_y + 3, "Start date", Color::White); | |
| 2909 | 2925 | assert_styled_text(&buffer, value_x, row_y, "A", Color::Gray); |
| 2910 | - assert_styled_text(&buffer, value_x, row_y + 2, "2026-04-23", Color::Gray); | |
| 2911 | - assert_styled_text(&buffer, value_x, row_y + 3, "09:00", Color::Gray); | |
| 2912 | - assert_styled_cell(&buffer, value_x, row_y + 1, "[", Color::Yellow); | |
| 2913 | - assert_styled_cell(&buffer, value_x + 1, row_y + 1, "x", Color::White); | |
| 2914 | - assert_styled_cell(&buffer, value_x + 2, row_y + 1, "]", Color::Yellow); | |
| 2915 | - assert_styled_cell(&buffer, value_x, row_y + 8, "[", Color::Yellow); | |
| 2916 | - assert_styled_cell(&buffer, value_x + 1, row_y + 8, " ", Color::White); | |
| 2917 | - assert_styled_cell(&buffer, value_x + 2, row_y + 8, "]", Color::Yellow); | |
| 2918 | - assert_styled_text(&buffer, value_x + 4, row_y + 8, "5m", Color::Gray); | |
| 2926 | + assert_styled_text(&buffer, value_x, row_y + 1, "Local", Color::Gray); | |
| 2927 | + assert_styled_text(&buffer, value_x, row_y + 3, "2026-04-23", Color::Gray); | |
| 2928 | + assert_styled_text(&buffer, value_x, row_y + 4, "09:00", Color::Gray); | |
| 2929 | + assert_styled_cell(&buffer, value_x, row_y + 2, "[", Color::Yellow); | |
| 2930 | + assert_styled_cell(&buffer, value_x + 1, row_y + 2, "x", Color::White); | |
| 2931 | + assert_styled_cell(&buffer, value_x + 2, row_y + 2, "]", Color::Yellow); | |
| 2932 | + assert_styled_cell(&buffer, value_x, row_y + 9, "[", Color::Yellow); | |
| 2933 | + assert_styled_cell(&buffer, value_x + 1, row_y + 9, " ", Color::White); | |
| 2934 | + assert_styled_cell(&buffer, value_x + 2, row_y + 9, "]", Color::Yellow); | |
| 2935 | + assert_styled_text(&buffer, value_x + 4, row_y + 9, "5m", Color::Gray); | |
| 2919 | 2936 | } |
| 2920 | 2937 | |
| 2921 | 2938 | #[test] |
@@ -2923,7 +2940,7 @@ mod tests { | ||
| 2923 | 2940 | let selected = date(2026, Month::April, 23); |
| 2924 | 2941 | let mut app = AppState::new(selected); |
| 2925 | 2942 | app.apply(AppAction::OpenCreate); |
| 2926 | - for _ in 0..7 { | |
| 2943 | + for _ in 0..8 { | |
| 2927 | 2944 | let _ = app.handle_create_key(key(KeyCode::Tab)); |
| 2928 | 2945 | } |
| 2929 | 2946 | let notes = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; |
@@ -2936,7 +2953,7 @@ mod tests { | ||
| 2936 | 2953 | let modal = create_modal_area(area, app.create_form().expect("form stays open")); |
| 2937 | 2954 | let content = inset_rect(modal); |
| 2938 | 2955 | let row_y = content.y.saturating_add(2); |
| 2939 | - let notes_y = row_y.saturating_add(7); | |
| 2956 | + let notes_y = row_y.saturating_add(8); | |
| 2940 | 2957 | let label_x = content.x.saturating_add(2); |
| 2941 | 2958 | let label_width = 12.min(content.width.saturating_sub(1)); |
| 2942 | 2959 | let value_x = label_x.saturating_add(label_width).saturating_add(1); |
@@ -2971,6 +2988,7 @@ mod tests { | ||
| 2971 | 2988 | assert_eq!(lines.len(), 10); |
| 2972 | 2989 | assert!(lines[1].contains("Create")); |
| 2973 | 2990 | assert!(rendered.contains("Title")); |
| 2991 | + assert!(rendered.contains("Left/Right")); | |
| 2974 | 2992 | assert!(rendered.contains("Ctrl-S save")); |
| 2975 | 2993 | } |
| 2976 | 2994 | |