Implement comprehensive external calendar event deduplication and fixes
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m11s
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m11s
- Add UID-based deduplication to prefer recurring events over single events with same UID - Implement RRULE-generated instance detection to filter duplicate occurrences - Add title normalization for case-insensitive matching and consolidation - Fix external calendar refresh button with proper error handling and loading states - Update context menu for external events to show only "View Event Details" option - Add comprehensive multi-pass deduplication: UID → title consolidation → RRULE filtering This resolves issues where Outlook calendars showed duplicate events with same UID but different RRULE states (e.g., "Dragster Stand Up" appearing both as recurring and single events). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc, Datelike};
|
||||||
use ical::parser::ical::component::IcalEvent;
|
use ical::parser::ical::component::IcalEvent;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -138,6 +138,9 @@ fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate events based on UID, start time, and summary
|
||||||
|
// Outlook sometimes includes duplicate events (recurring exceptions may appear multiple times)
|
||||||
|
events = deduplicate_events(events);
|
||||||
|
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
@@ -407,4 +410,439 @@ fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<Date
|
|||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicate events based on UID, start time, and summary
|
||||||
|
/// Some calendar systems (like Outlook) may include duplicate events in ICS feeds
|
||||||
|
/// This includes both exact duplicates and recurring event instances that would be
|
||||||
|
/// generated by existing RRULE patterns, and events with same title but different
|
||||||
|
/// RRULE patterns that should be consolidated
|
||||||
|
fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let original_count = events.len();
|
||||||
|
|
||||||
|
// First pass: Group by UID and prefer recurring events over single events with same UID
|
||||||
|
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
for event in events.drain(..) {
|
||||||
|
// Debug logging to understand what's happening
|
||||||
|
println!("🔍 Event: '{}' at {} (RRULE: {}) - UID: {}",
|
||||||
|
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
event.dtstart.format("%Y-%m-%d %H:%M"),
|
||||||
|
if event.rrule.is_some() { "Yes" } else { "No" },
|
||||||
|
event.uid
|
||||||
|
);
|
||||||
|
|
||||||
|
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut uid_deduplicated_events = Vec::new();
|
||||||
|
|
||||||
|
for (uid, mut events_with_uid) in uid_groups.drain() {
|
||||||
|
if events_with_uid.len() == 1 {
|
||||||
|
// Only one event with this UID, keep it
|
||||||
|
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
|
||||||
|
} else {
|
||||||
|
// Multiple events with same UID - prefer recurring over non-recurring
|
||||||
|
println!("🔍 Found {} events with UID '{}'", events_with_uid.len(), uid);
|
||||||
|
|
||||||
|
// Sort by preference: recurring events first, then by completeness
|
||||||
|
events_with_uid.sort_by(|a, b| {
|
||||||
|
let a_has_rrule = a.rrule.is_some();
|
||||||
|
let b_has_rrule = b.rrule.is_some();
|
||||||
|
|
||||||
|
match (a_has_rrule, b_has_rrule) {
|
||||||
|
(true, false) => std::cmp::Ordering::Less, // a (recurring) comes first
|
||||||
|
(false, true) => std::cmp::Ordering::Greater, // b (recurring) comes first
|
||||||
|
_ => {
|
||||||
|
// Both same type (both recurring or both single) - compare by completeness
|
||||||
|
event_completeness_score(b).cmp(&event_completeness_score(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the first (preferred) event
|
||||||
|
let preferred_event = events_with_uid.into_iter().next().unwrap();
|
||||||
|
println!("🔄 UID dedup: Keeping '{}' (RRULE: {})",
|
||||||
|
preferred_event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
if preferred_event.rrule.is_some() { "Yes" } else { "No" }
|
||||||
|
);
|
||||||
|
uid_deduplicated_events.push(preferred_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: separate recurring and single events from UID-deduplicated set
|
||||||
|
let mut recurring_events = Vec::new();
|
||||||
|
let mut single_events = Vec::new();
|
||||||
|
|
||||||
|
for event in uid_deduplicated_events.drain(..) {
|
||||||
|
if event.rrule.is_some() {
|
||||||
|
recurring_events.push(event);
|
||||||
|
} else {
|
||||||
|
single_events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third pass: Group recurring events by normalized title and consolidate different RRULE patterns
|
||||||
|
let mut title_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
for event in recurring_events.drain(..) {
|
||||||
|
let title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
title_groups.entry(title).or_insert_with(Vec::new).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deduplicated_recurring = Vec::new();
|
||||||
|
|
||||||
|
for (title, events_with_title) in title_groups.drain() {
|
||||||
|
if events_with_title.len() == 1 {
|
||||||
|
// Single event with this title, keep as-is
|
||||||
|
deduplicated_recurring.push(events_with_title.into_iter().next().unwrap());
|
||||||
|
} else {
|
||||||
|
// Multiple events with same title - consolidate or deduplicate
|
||||||
|
println!("🔍 Found {} events with title '{}'", events_with_title.len(), title);
|
||||||
|
|
||||||
|
// Check if these are actually different recurring patterns for the same logical event
|
||||||
|
let consolidated = consolidate_same_title_events(events_with_title);
|
||||||
|
deduplicated_recurring.extend(consolidated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fourth pass: filter single events, removing those that would be generated by recurring events
|
||||||
|
let mut deduplicated_single = Vec::new();
|
||||||
|
let mut seen_single: HashMap<String, usize> = HashMap::new();
|
||||||
|
|
||||||
|
for event in single_events.drain(..) {
|
||||||
|
let normalized_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
let dedup_key = format!(
|
||||||
|
"{}|{}",
|
||||||
|
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||||
|
normalized_title
|
||||||
|
);
|
||||||
|
|
||||||
|
// First check for exact duplicates among single events
|
||||||
|
if let Some(&existing_index) = seen_single.get(&dedup_key) {
|
||||||
|
let existing_event: &VEvent = &deduplicated_single[existing_index];
|
||||||
|
let current_completeness = event_completeness_score(&event);
|
||||||
|
let existing_completeness = event_completeness_score(existing_event);
|
||||||
|
|
||||||
|
if current_completeness > existing_completeness {
|
||||||
|
println!("🔄 Replacing single event: Keeping '{}' over '{}'",
|
||||||
|
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||||
|
);
|
||||||
|
deduplicated_single[existing_index] = event;
|
||||||
|
} else {
|
||||||
|
println!("🚫 Discarding duplicate single event: Keeping existing '{}'",
|
||||||
|
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this single event would be generated by any recurring event
|
||||||
|
let is_rrule_generated = deduplicated_recurring.iter().any(|recurring_event| {
|
||||||
|
// Check if this single event matches the recurring event's pattern (use normalized titles)
|
||||||
|
let single_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
let recurring_title = normalize_title(recurring_event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
|
||||||
|
if single_title != recurring_title {
|
||||||
|
return false; // Different events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this single event would be generated by the recurring event
|
||||||
|
would_event_be_generated_by_rrule(recurring_event, &event)
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_rrule_generated {
|
||||||
|
println!("🚫 Discarding RRULE-generated instance: '{}' at {} would be generated by recurring event",
|
||||||
|
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
event.dtstart.format("%Y-%m-%d %H:%M")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// This is a unique single event
|
||||||
|
seen_single.insert(dedup_key, deduplicated_single.len());
|
||||||
|
deduplicated_single.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine recurring and single events
|
||||||
|
let mut result = deduplicated_recurring;
|
||||||
|
result.extend(deduplicated_single);
|
||||||
|
|
||||||
|
println!("📊 Deduplication complete: {} -> {} events ({} recurring, {} single)",
|
||||||
|
original_count, result.len(),
|
||||||
|
result.iter().filter(|e| e.rrule.is_some()).count(),
|
||||||
|
result.iter().filter(|e| e.rrule.is_none()).count()
|
||||||
|
);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize title for grouping similar events
|
||||||
|
fn normalize_title(title: &str) -> String {
|
||||||
|
title.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consolidate events with the same title but potentially different RRULE patterns
|
||||||
|
/// This handles cases where calendar systems provide multiple recurring definitions
|
||||||
|
/// for the same logical meeting (e.g., one RRULE for Tuesdays, another for Thursdays)
|
||||||
|
fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
if events.is_empty() {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the RRULEs we're working with
|
||||||
|
for event in &events {
|
||||||
|
if let Some(rrule) = &event.rrule {
|
||||||
|
println!("🔍 RRULE for '{}': {}",
|
||||||
|
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
rrule
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all events have similar time patterns and could be consolidated
|
||||||
|
let first_event = &events[0];
|
||||||
|
let base_time = first_event.dtstart.time();
|
||||||
|
let base_duration = if let Some(end) = first_event.dtend {
|
||||||
|
Some(end.signed_duration_since(first_event.dtstart))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if all events have the same time and duration
|
||||||
|
let can_consolidate = events.iter().all(|event| {
|
||||||
|
let same_time = event.dtstart.time() == base_time;
|
||||||
|
let same_duration = match (event.dtend, base_duration) {
|
||||||
|
(Some(end), Some(base_dur)) => end.signed_duration_since(event.dtstart) == base_dur,
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
same_time && same_duration
|
||||||
|
});
|
||||||
|
|
||||||
|
if !can_consolidate {
|
||||||
|
println!("🚫 Cannot consolidate events - different times or durations");
|
||||||
|
// Just deduplicate exact duplicates
|
||||||
|
return deduplicate_exact_recurring_events(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect if these are complementary weekly patterns
|
||||||
|
let weekly_events: Vec<_> = events.iter()
|
||||||
|
.filter(|e| e.rrule.as_ref().map_or(false, |r| r.contains("FREQ=WEEKLY")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if weekly_events.len() >= 2 && weekly_events.len() == events.len() {
|
||||||
|
// All events are weekly - try to consolidate into a single multi-day weekly pattern
|
||||||
|
if let Some(consolidated) = consolidate_weekly_patterns(&events) {
|
||||||
|
println!("✅ Successfully consolidated {} weekly patterns into one", events.len());
|
||||||
|
return vec![consolidated];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't consolidate, just deduplicate exact matches and keep the most complete one
|
||||||
|
println!("🚫 Cannot consolidate - keeping most complete event");
|
||||||
|
let deduplicated = deduplicate_exact_recurring_events(events);
|
||||||
|
|
||||||
|
// If we still have multiple events, keep only the most complete one
|
||||||
|
if deduplicated.len() > 1 {
|
||||||
|
let best_event = deduplicated.into_iter()
|
||||||
|
.max_by_key(|e| event_completeness_score(e))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("🎯 Kept most complete event: '{}'",
|
||||||
|
best_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||||
|
);
|
||||||
|
vec![best_event]
|
||||||
|
} else {
|
||||||
|
deduplicated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicate exact recurring event matches
|
||||||
|
fn deduplicate_exact_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let mut seen: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut deduplicated = Vec::new();
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let dedup_key = format!(
|
||||||
|
"{}|{}|{}",
|
||||||
|
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||||
|
event.summary.as_ref().unwrap_or(&String::new()),
|
||||||
|
event.rrule.as_ref().unwrap_or(&String::new())
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(&existing_index) = seen.get(&dedup_key) {
|
||||||
|
let existing_event: &VEvent = &deduplicated[existing_index];
|
||||||
|
let current_completeness = event_completeness_score(&event);
|
||||||
|
let existing_completeness = event_completeness_score(existing_event);
|
||||||
|
|
||||||
|
if current_completeness > existing_completeness {
|
||||||
|
println!("🔄 Replacing exact duplicate: Keeping more complete event");
|
||||||
|
deduplicated[existing_index] = event;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seen.insert(dedup_key, deduplicated.len());
|
||||||
|
deduplicated.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deduplicated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to consolidate multiple weekly RRULE patterns into a single pattern
|
||||||
|
fn consolidate_weekly_patterns(events: &[VEvent]) -> Option<VEvent> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let mut all_days = HashSet::new();
|
||||||
|
let mut base_event = None;
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let Some(rrule) = &event.rrule else { continue; };
|
||||||
|
|
||||||
|
if !rrule.contains("FREQ=WEEKLY") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract BYDAY if present
|
||||||
|
if let Some(byday_part) = rrule.split(';').find(|part| part.starts_with("BYDAY=")) {
|
||||||
|
let days_str = byday_part.strip_prefix("BYDAY=").unwrap_or("");
|
||||||
|
for day in days_str.split(',') {
|
||||||
|
all_days.insert(day.trim().to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no BYDAY specified, use the weekday from the start date
|
||||||
|
let weekday = match event.dtstart.weekday() {
|
||||||
|
chrono::Weekday::Mon => "MO",
|
||||||
|
chrono::Weekday::Tue => "TU",
|
||||||
|
chrono::Weekday::Wed => "WE",
|
||||||
|
chrono::Weekday::Thu => "TH",
|
||||||
|
chrono::Weekday::Fri => "FR",
|
||||||
|
chrono::Weekday::Sat => "SA",
|
||||||
|
chrono::Weekday::Sun => "SU",
|
||||||
|
};
|
||||||
|
all_days.insert(weekday.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first event as the base (we already know they have same time/duration)
|
||||||
|
if base_event.is_none() {
|
||||||
|
base_event = Some(event.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_days.is_empty() || base_event.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create consolidated RRULE
|
||||||
|
let mut base = base_event.unwrap();
|
||||||
|
let days_list: Vec<_> = all_days.into_iter().collect();
|
||||||
|
let byday_str = days_list.join(",");
|
||||||
|
|
||||||
|
// Build new RRULE with consolidated BYDAY
|
||||||
|
let new_rrule = if let Some(existing_rrule) = &base.rrule {
|
||||||
|
// Remove existing BYDAY and add our consolidated one
|
||||||
|
let parts: Vec<_> = existing_rrule.split(';')
|
||||||
|
.filter(|part| !part.starts_with("BYDAY="))
|
||||||
|
.collect();
|
||||||
|
format!("{};BYDAY={}", parts.join(";"), byday_str)
|
||||||
|
} else {
|
||||||
|
format!("FREQ=WEEKLY;BYDAY={}", byday_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
base.rrule = Some(new_rrule);
|
||||||
|
|
||||||
|
println!("🔗 Consolidated weekly pattern: BYDAY={}", byday_str);
|
||||||
|
Some(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a single event would be generated by a recurring event's RRULE
|
||||||
|
fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VEvent) -> bool {
|
||||||
|
let Some(rrule) = &recurring_event.rrule else {
|
||||||
|
return false; // No RRULE to check against
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse basic RRULE patterns
|
||||||
|
if rrule.contains("FREQ=DAILY") {
|
||||||
|
// Daily recurrence
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
|
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
|
||||||
|
|
||||||
|
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
||||||
|
// Check if times match (allowing for timezone differences within same day)
|
||||||
|
let recurring_time = recurring_event.dtstart.time();
|
||||||
|
let single_time = single_event.dtstart.time();
|
||||||
|
return recurring_time == single_time;
|
||||||
|
}
|
||||||
|
} else if rrule.contains("FREQ=WEEKLY") {
|
||||||
|
// Weekly recurrence
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
|
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
|
||||||
|
|
||||||
|
// First check if it's the same day of week and time
|
||||||
|
let recurring_weekday = recurring_event.dtstart.weekday();
|
||||||
|
let single_weekday = single_event.dtstart.weekday();
|
||||||
|
let recurring_time = recurring_event.dtstart.time();
|
||||||
|
let single_time = single_event.dtstart.time();
|
||||||
|
|
||||||
|
if recurring_weekday == single_weekday && recurring_time == single_time && days_diff >= 0 {
|
||||||
|
// Calculate how many weeks apart they are
|
||||||
|
let weeks_diff = days_diff / 7;
|
||||||
|
// Check if this falls on an interval boundary
|
||||||
|
return weeks_diff % interval as i64 == 0;
|
||||||
|
}
|
||||||
|
} else if rrule.contains("FREQ=MONTHLY") {
|
||||||
|
// Monthly recurrence - simplified check
|
||||||
|
let months_diff = (single_event.dtstart.year() - recurring_event.dtstart.year()) * 12
|
||||||
|
+ (single_event.dtstart.month() as i32 - recurring_event.dtstart.month() as i32);
|
||||||
|
|
||||||
|
if months_diff >= 0 {
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1) as i32;
|
||||||
|
if months_diff % interval == 0 {
|
||||||
|
// Same day of month and time
|
||||||
|
return recurring_event.dtstart.day() == single_event.dtstart.day()
|
||||||
|
&& recurring_event.dtstart.time() == single_event.dtstart.time();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract INTERVAL value from RRULE string, defaulting to 1 if not found
|
||||||
|
fn extract_interval_from_rrule(rrule: &str) -> Option<u32> {
|
||||||
|
for part in rrule.split(';') {
|
||||||
|
if part.starts_with("INTERVAL=") {
|
||||||
|
return part.strip_prefix("INTERVAL=")
|
||||||
|
.and_then(|s| s.parse().ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(1) // Default interval is 1 if not specified
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate a completeness score for an event based on how many optional fields are filled
|
||||||
|
fn event_completeness_score(event: &VEvent) -> u32 {
|
||||||
|
let mut score = 0;
|
||||||
|
|
||||||
|
if event.summary.is_some() { score += 1; }
|
||||||
|
if event.description.is_some() { score += 1; }
|
||||||
|
if event.location.is_some() { score += 1; }
|
||||||
|
if event.dtend.is_some() { score += 1; }
|
||||||
|
if event.rrule.is_some() { score += 1; }
|
||||||
|
if !event.categories.is_empty() { score += 1; }
|
||||||
|
if !event.alarms.is_empty() { score += 1; }
|
||||||
|
if event.organizer.is_some() { score += 1; }
|
||||||
|
if !event.attendees.is_empty() { score += 1; }
|
||||||
|
|
||||||
|
score
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::components::{
|
use crate::components::{
|
||||||
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
|
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
|
||||||
EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler,
|
EditAction, EventContextMenu, EventModal, EventCreationData, ExternalCalendarModal, RouteHandler,
|
||||||
Sidebar, Theme, ViewMode,
|
Sidebar, Theme, ViewMode,
|
||||||
};
|
};
|
||||||
use crate::components::sidebar::{Style};
|
use crate::components::sidebar::{Style};
|
||||||
@@ -72,6 +72,9 @@ pub fn App() -> Html {
|
|||||||
let create_event_modal_open = use_state(|| false);
|
let create_event_modal_open = use_state(|| false);
|
||||||
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
|
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
|
||||||
let event_edit_scope = use_state(|| -> Option<EditAction> { None });
|
let event_edit_scope = use_state(|| -> Option<EditAction> { None });
|
||||||
|
let view_event_modal_open = use_state(|| false);
|
||||||
|
let view_event_modal_event = use_state(|| -> Option<VEvent> { None });
|
||||||
|
let refreshing_calendar_id = use_state(|| -> Option<i32> { None });
|
||||||
let _recurring_edit_modal_open = use_state(|| false);
|
let _recurring_edit_modal_open = use_state(|| false);
|
||||||
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
|
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
|
||||||
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
|
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
|
||||||
@@ -1112,34 +1115,65 @@ pub fn App() -> Html {
|
|||||||
on_external_calendar_refresh={Callback::from({
|
on_external_calendar_refresh={Callback::from({
|
||||||
let external_calendar_events = external_calendar_events.clone();
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
let external_calendars = external_calendars.clone();
|
let external_calendars = external_calendars.clone();
|
||||||
|
let refreshing_calendar_id = refreshing_calendar_id.clone();
|
||||||
move |id: i32| {
|
move |id: i32| {
|
||||||
let external_calendar_events = external_calendar_events.clone();
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
let external_calendars = external_calendars.clone();
|
let external_calendars = external_calendars.clone();
|
||||||
|
let refreshing_calendar_id = refreshing_calendar_id.clone();
|
||||||
|
|
||||||
|
// Set loading state
|
||||||
|
refreshing_calendar_id.set(Some(id));
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
web_sys::console::log_1(&format!("🔄 Refreshing external calendar {}", id).into());
|
||||||
|
|
||||||
// Force refresh of this specific calendar
|
// Force refresh of this specific calendar
|
||||||
if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await {
|
match CalendarService::fetch_external_calendar_events(id).await {
|
||||||
// Set calendar_path for color matching
|
Ok(mut events) => {
|
||||||
for event in &mut events {
|
web_sys::console::log_1(&format!("✅ Successfully refreshed calendar {} with {} events", id, events.len()).into());
|
||||||
event.calendar_path = Some(format!("external_{}", id));
|
|
||||||
}
|
// Set calendar_path for color matching
|
||||||
|
for event in &mut events {
|
||||||
// Update events for this calendar
|
event.calendar_path = Some(format!("external_{}", id));
|
||||||
let mut all_events = (*external_calendar_events).clone();
|
|
||||||
// Remove old events from this calendar
|
|
||||||
all_events.retain(|e| {
|
|
||||||
if let Some(ref calendar_path) = e.calendar_path {
|
|
||||||
calendar_path != &format!("external_{}", id)
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
// Add new events
|
// Update events for this calendar
|
||||||
all_events.extend(events);
|
let mut all_events = (*external_calendar_events).clone();
|
||||||
external_calendar_events.set(all_events);
|
// Remove old events from this calendar
|
||||||
|
all_events.retain(|e| {
|
||||||
// Update the last_fetched timestamp in calendars list
|
if let Some(ref calendar_path) = e.calendar_path {
|
||||||
if let Ok(calendars) = CalendarService::get_external_calendars().await {
|
calendar_path != &format!("external_{}", id)
|
||||||
external_calendars.set(calendars);
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Add new events
|
||||||
|
all_events.extend(events);
|
||||||
|
external_calendar_events.set(all_events);
|
||||||
|
|
||||||
|
// Update the last_fetched timestamp in calendars list
|
||||||
|
match CalendarService::get_external_calendars().await {
|
||||||
|
Ok(calendars) => {
|
||||||
|
external_calendars.set(calendars);
|
||||||
|
web_sys::console::log_1(&"✅ Calendar list updated with new timestamps".into());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::error_1(&format!("⚠️ Failed to update calendar list: {}", err).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear loading state on success
|
||||||
|
refreshing_calendar_id.set(None);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::error_1(&format!("❌ Failed to refresh calendar {}: {}", id, err).into());
|
||||||
|
// Show error to user
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.alert_with_message(&format!("Failed to refresh calendar: {}", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear loading state on error
|
||||||
|
refreshing_calendar_id.set(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1149,6 +1183,7 @@ pub fn App() -> Html {
|
|||||||
on_color_change={on_color_change}
|
on_color_change={on_color_change}
|
||||||
on_color_picker_toggle={on_color_picker_toggle}
|
on_color_picker_toggle={on_color_picker_toggle}
|
||||||
available_colors={(*available_colors).clone()}
|
available_colors={(*available_colors).clone()}
|
||||||
|
refreshing_calendar_id={(*refreshing_calendar_id).clone()}
|
||||||
on_calendar_context_menu={on_calendar_context_menu}
|
on_calendar_context_menu={on_calendar_context_menu}
|
||||||
on_calendar_visibility_toggle={Callback::from({
|
on_calendar_visibility_toggle={Callback::from({
|
||||||
let user_info = user_info.clone();
|
let user_info = user_info.clone();
|
||||||
@@ -1397,6 +1432,17 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
on_view_details={Callback::from({
|
||||||
|
let event_context_menu_open = event_context_menu_open.clone();
|
||||||
|
let view_event_modal_open = view_event_modal_open.clone();
|
||||||
|
let view_event_modal_event = view_event_modal_event.clone();
|
||||||
|
move |event: VEvent| {
|
||||||
|
// Set the event for viewing (read-only mode)
|
||||||
|
view_event_modal_event.set(Some(event));
|
||||||
|
event_context_menu_open.set(false);
|
||||||
|
view_event_modal_open.set(true);
|
||||||
|
}
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CalendarContextMenu
|
<CalendarContextMenu
|
||||||
@@ -1484,6 +1530,18 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EventModal
|
||||||
|
event={if *view_event_modal_open { (*view_event_modal_event).clone() } else { None }}
|
||||||
|
on_close={Callback::from({
|
||||||
|
let view_event_modal_open = view_event_modal_open.clone();
|
||||||
|
let view_event_modal_event = view_event_modal_event.clone();
|
||||||
|
move |_| {
|
||||||
|
view_event_modal_open.set(false);
|
||||||
|
view_event_modal_event.set(None);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct EventContextMenuProps {
|
|||||||
pub event: Option<VEvent>,
|
pub event: Option<VEvent>,
|
||||||
pub on_edit: Callback<EditAction>,
|
pub on_edit: Callback<EditAction>,
|
||||||
pub on_delete: Callback<DeleteAction>,
|
pub on_delete: Callback<DeleteAction>,
|
||||||
|
pub on_view_details: Callback<VEvent>,
|
||||||
pub on_close: Callback<()>,
|
pub on_close: Callback<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +91,14 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|event| event.rrule.is_some())
|
.map(|event| event.rrule.is_some())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Check if the event is from an external calendar (read-only)
|
||||||
|
let is_external = props
|
||||||
|
.event
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|event| event.calendar_path.as_ref())
|
||||||
|
.map(|path| path.starts_with("external_"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let create_edit_callback = |action: EditAction| {
|
let create_edit_callback = |action: EditAction| {
|
||||||
let on_edit = props.on_edit.clone();
|
let on_edit = props.on_edit.clone();
|
||||||
@@ -109,6 +118,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let create_view_details_callback = {
|
||||||
|
let on_view_details = props.on_view_details.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let event = props.event.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(event) = &event {
|
||||||
|
on_view_details.emit(event.clone());
|
||||||
|
}
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
@@ -116,7 +137,15 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
if is_recurring {
|
if is_external {
|
||||||
|
// External calendar events are read-only - only show "View Details"
|
||||||
|
html! {
|
||||||
|
<div class="context-menu-item" onclick={create_view_details_callback}>
|
||||||
|
{"View Event Details"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if is_recurring {
|
||||||
|
// Regular recurring events - show edit options
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||||
@@ -131,6 +160,7 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Regular single events - show edit option
|
||||||
html! {
|
html! {
|
||||||
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||||
{"Edit Event"}
|
{"Edit Event"}
|
||||||
@@ -139,26 +169,32 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
if is_recurring {
|
if !is_external {
|
||||||
html! {
|
// Only show delete options for non-external events
|
||||||
<>
|
if is_recurring {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||||
|
{"Delete This Event"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
||||||
|
{"Delete Following Events"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
||||||
|
{"Delete Entire Series"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||||
{"Delete This Event"}
|
{"Delete Event"}
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
}
|
||||||
{"Delete Following Events"}
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
|
||||||
{"Delete Entire Series"}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
// No delete options for external events
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
html! {}
|
||||||
{"Delete Event"}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ pub struct SidebarProps {
|
|||||||
pub on_color_change: Callback<(String, String)>,
|
pub on_color_change: Callback<(String, String)>,
|
||||||
pub on_color_picker_toggle: Callback<String>,
|
pub on_color_picker_toggle: Callback<String>,
|
||||||
pub available_colors: Vec<String>,
|
pub available_colors: Vec<String>,
|
||||||
|
pub refreshing_calendar_id: Option<i32>,
|
||||||
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||||
pub on_calendar_visibility_toggle: Callback<String>,
|
pub on_calendar_visibility_toggle: Callback<String>,
|
||||||
pub current_view: ViewMode,
|
pub current_view: ViewMode,
|
||||||
@@ -304,8 +305,15 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
on_refresh.emit(cal_id);
|
on_refresh.emit(cal_id);
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
disabled={props.refreshing_calendar_id == Some(cal.id)}
|
||||||
>
|
>
|
||||||
{"🔄"}
|
{
|
||||||
|
if props.refreshing_calendar_id == Some(cal.id) {
|
||||||
|
"⏳" // Loading spinner
|
||||||
|
} else {
|
||||||
|
"🔄" // Normal refresh icon
|
||||||
|
}
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user