Compare commits
14 Commits
print-prev
...
fd80624429
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd80624429 | ||
|
|
b530dcaa69 | ||
|
|
0821573041 | ||
|
|
703c9ee2f5 | ||
|
|
5854ad291d | ||
|
|
ac1164fd81 | ||
|
|
a6092d13ce | ||
|
|
acc5ced551 | ||
|
|
890940fe31 | ||
|
|
fdea5cd646 | ||
|
|
b307be7eb1 | ||
|
|
9d84c380d1 | ||
|
|
fad03f94f9 | ||
| a4476dcfae |
@@ -4,7 +4,7 @@
|
|||||||

|

|
||||||
|
|
||||||
>[!WARNING]
|
>[!WARNING]
|
||||||
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty solid.
|
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty decent. There are still a lot of places where the AI has implemented some really poor solutions to the problems that I didn't catch, but I've begun using it for my own general use.
|
||||||
|
|
||||||
A modern CalDAV web client built with Rust WebAssembly.
|
A modern CalDAV web client built with Rust WebAssembly.
|
||||||
|
|
||||||
|
|||||||
@@ -39,19 +39,13 @@ impl AuthService {
|
|||||||
request.username.clone(),
|
request.username.clone(),
|
||||||
request.password.clone(),
|
request.password.clone(),
|
||||||
);
|
);
|
||||||
println!("📝 Created CalDAV config");
|
|
||||||
|
|
||||||
// Test authentication against CalDAV server
|
// Test authentication against CalDAV server
|
||||||
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
|
||||||
|
|
||||||
// Try to discover calendars as an authentication test
|
// Try to discover calendars as an authentication test
|
||||||
match caldav_client.discover_calendars().await {
|
match caldav_client.discover_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(_calendars) => {
|
||||||
println!(
|
|
||||||
"✅ Authentication successful! Found {} calendars",
|
|
||||||
calendars.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find or create user in database
|
// Find or create user in database
|
||||||
let user_repo = UserRepository::new(&self.db);
|
let user_repo = UserRepository::new(&self.db);
|
||||||
|
|||||||
@@ -167,8 +167,6 @@ impl CalDAVClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let basic_auth = self.config.get_basic_auth();
|
let basic_auth = self.config.get_basic_auth();
|
||||||
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
|
|
||||||
println!("🌐 REPORT URL: {}", url);
|
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
@@ -349,6 +347,8 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
full_prop.push_str(&format!(":{}", prop_value));
|
full_prop.push_str(&format!(":{}", prop_value));
|
||||||
|
|
||||||
|
|
||||||
full_properties.insert(prop_name, full_prop);
|
full_properties.insert(prop_name, full_prop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,27 +358,30 @@ impl CalDAVClient {
|
|||||||
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
|
// Determine if it's an all-day event FIRST by checking for VALUE=DATE parameter per RFC 5545
|
||||||
|
let empty_string = String::new();
|
||||||
|
let dtstart_raw = full_properties.get("DTSTART").unwrap_or(&empty_string);
|
||||||
|
let dtstart_value = properties.get("DTSTART").unwrap_or(&empty_string);
|
||||||
|
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_value.contains("T") && dtstart_value.len() == 8);
|
||||||
|
|
||||||
|
|
||||||
// Parse start time (required)
|
// Parse start time (required)
|
||||||
let start = properties
|
let start_prop = properties
|
||||||
.get("DTSTART")
|
.get("DTSTART")
|
||||||
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
||||||
let start = self.parse_datetime(start, full_properties.get("DTSTART"))?;
|
let (start_naive, start_tzid) = self.parse_datetime_with_tz(start_prop, full_properties.get("DTSTART"))?;
|
||||||
|
|
||||||
// Parse end time (optional - use start time if not present)
|
// Parse end time (optional - use start time if not present)
|
||||||
let end = if let Some(dtend) = properties.get("DTEND") {
|
let (end_naive, end_tzid) = if let Some(dtend) = properties.get("DTEND") {
|
||||||
Some(self.parse_datetime(dtend, full_properties.get("DTEND"))?)
|
let (end_dt, end_tz) = self.parse_datetime_with_tz(dtend, full_properties.get("DTEND"))?;
|
||||||
|
(Some(end_dt), end_tz)
|
||||||
} else if let Some(_duration) = properties.get("DURATION") {
|
} else if let Some(_duration) = properties.get("DURATION") {
|
||||||
// TODO: Parse duration and add to start time
|
// TODO: Parse duration and add to start time
|
||||||
Some(start)
|
(Some(start_naive), start_tzid.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if it's an all-day event by checking for VALUE=DATE parameter
|
|
||||||
let empty_string = String::new();
|
|
||||||
let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string);
|
|
||||||
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8);
|
|
||||||
|
|
||||||
// Parse status
|
// Parse status
|
||||||
let status = properties
|
let status = properties
|
||||||
.get("STATUS")
|
.get("STATUS")
|
||||||
@@ -411,23 +414,35 @@ impl CalDAVClient {
|
|||||||
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Parse dates
|
// Parse dates with timezone information
|
||||||
let created = properties
|
let (created_naive, created_tzid) = if let Some(created_str) = properties.get("CREATED") {
|
||||||
.get("CREATED")
|
match self.parse_datetime_with_tz(created_str, None) {
|
||||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
Ok((dt, tz)) => (Some(dt), tz),
|
||||||
|
Err(_) => (None, None)
|
||||||
let last_modified = properties
|
}
|
||||||
.get("LAST-MODIFIED")
|
} else {
|
||||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (last_modified_naive, last_modified_tzid) = if let Some(modified_str) = properties.get("LAST-MODIFIED") {
|
||||||
|
match self.parse_datetime_with_tz(modified_str, None) {
|
||||||
|
Ok((dt, tz)) => (Some(dt), tz),
|
||||||
|
Err(_) => (None, None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
// Parse exception dates (EXDATE)
|
// Parse exception dates (EXDATE)
|
||||||
let exdate = self.parse_exdate(&event);
|
let exdate = self.parse_exdate(&event);
|
||||||
|
|
||||||
// Create VEvent with required fields
|
// Create VEvent with parsed naive datetime and timezone info
|
||||||
let mut vevent = VEvent::new(uid, start);
|
let mut vevent = VEvent::new(uid, start_naive);
|
||||||
|
|
||||||
// Set optional fields
|
// Set optional fields with timezone information
|
||||||
vevent.dtend = end;
|
vevent.dtend = end_naive;
|
||||||
|
vevent.dtstart_tzid = start_tzid;
|
||||||
|
vevent.dtend_tzid = end_tzid;
|
||||||
vevent.summary = properties.get("SUMMARY").cloned();
|
vevent.summary = properties.get("SUMMARY").cloned();
|
||||||
vevent.description = properties.get("DESCRIPTION").cloned();
|
vevent.description = properties.get("DESCRIPTION").cloned();
|
||||||
vevent.location = properties.get("LOCATION").cloned();
|
vevent.location = properties.get("LOCATION").cloned();
|
||||||
@@ -450,10 +465,13 @@ impl CalDAVClient {
|
|||||||
vevent.attendees = Vec::new();
|
vevent.attendees = Vec::new();
|
||||||
|
|
||||||
vevent.categories = categories;
|
vevent.categories = categories;
|
||||||
vevent.created = created;
|
vevent.created = created_naive;
|
||||||
vevent.last_modified = last_modified;
|
vevent.created_tzid = created_tzid;
|
||||||
|
vevent.last_modified = last_modified_naive;
|
||||||
|
vevent.last_modified_tzid = last_modified_tzid;
|
||||||
vevent.rrule = properties.get("RRULE").cloned();
|
vevent.rrule = properties.get("RRULE").cloned();
|
||||||
vevent.exdate = exdate;
|
vevent.exdate = exdate.into_iter().map(|dt| dt.naive_utc()).collect();
|
||||||
|
vevent.exdate_tzid = None; // TODO: Parse timezone info for EXDATE
|
||||||
vevent.all_day = all_day;
|
vevent.all_day = all_day;
|
||||||
|
|
||||||
// Parse alarms
|
// Parse alarms
|
||||||
@@ -566,11 +584,9 @@ impl CalDAVClient {
|
|||||||
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
|
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
|
||||||
// First, try to discover user calendars if we have a calendar path in config
|
// First, try to discover user calendars if we have a calendar path in config
|
||||||
if let Some(calendar_path) = &self.config.calendar_path {
|
if let Some(calendar_path) = &self.config.calendar_path {
|
||||||
println!("Using configured calendar path: {}", calendar_path);
|
|
||||||
return Ok(vec![calendar_path.clone()]);
|
return Ok(vec![calendar_path.clone()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("No calendar path configured, discovering calendars...");
|
|
||||||
|
|
||||||
// Try different common CalDAV discovery paths
|
// Try different common CalDAV discovery paths
|
||||||
// Note: paths should be relative to the server URL base
|
// Note: paths should be relative to the server URL base
|
||||||
@@ -583,20 +599,16 @@ impl CalDAVClient {
|
|||||||
let mut has_valid_caldav_response = false;
|
let mut has_valid_caldav_response = false;
|
||||||
|
|
||||||
for path in discovery_paths {
|
for path in discovery_paths {
|
||||||
println!("Trying discovery path: {}", path);
|
|
||||||
match self.discover_calendars_at_path(&path).await {
|
match self.discover_calendars_at_path(&path).await {
|
||||||
Ok(calendars) => {
|
Ok(calendars) => {
|
||||||
println!("Found {} calendar(s) at {}", calendars.len(), path);
|
|
||||||
has_valid_caldav_response = true;
|
has_valid_caldav_response = true;
|
||||||
all_calendars.extend(calendars);
|
all_calendars.extend(calendars);
|
||||||
}
|
}
|
||||||
Err(CalDAVError::ServerError(status)) => {
|
Err(CalDAVError::ServerError(_status)) => {
|
||||||
// HTTP error - this might be expected for some paths, continue trying
|
// HTTP error - this might be expected for some paths, continue trying
|
||||||
println!("Discovery path {} returned HTTP {}, trying next path", path, status);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
|
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
|
||||||
println!("Discovery failed for path {}: {:?}", path, e);
|
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -653,7 +665,6 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
||||||
println!("Discovery response for {}: {}", path, body);
|
|
||||||
|
|
||||||
let mut calendar_paths = Vec::new();
|
let mut calendar_paths = Vec::new();
|
||||||
|
|
||||||
@@ -664,7 +675,6 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Extract href first
|
// Extract href first
|
||||||
if let Some(href) = self.extract_xml_content(response_content, "href") {
|
if let Some(href) = self.extract_xml_content(response_content, "href") {
|
||||||
println!("🔍 Checking resource: {}", href);
|
|
||||||
|
|
||||||
// Check if this is a calendar collection by looking for supported-calendar-component-set
|
// Check if this is a calendar collection by looking for supported-calendar-component-set
|
||||||
// This indicates it's an actual calendar that can contain events
|
// This indicates it's an actual calendar that can contain events
|
||||||
@@ -688,14 +698,10 @@ impl CalDAVClient {
|
|||||||
&& !href.ends_with("/calendars/")
|
&& !href.ends_with("/calendars/")
|
||||||
&& href.ends_with('/')
|
&& href.ends_with('/')
|
||||||
{
|
{
|
||||||
println!("📅 Found calendar collection: {}", href);
|
|
||||||
calendar_paths.push(href);
|
calendar_paths.push(href);
|
||||||
} else {
|
} else {
|
||||||
println!("❌ Skipping system/root directory: {}", href);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("ℹ️ Not a calendar collection: {} (is_calendar: {}, has_collection: {})",
|
|
||||||
href, is_calendar, has_collection);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,6 +710,99 @@ impl CalDAVClient {
|
|||||||
Ok(calendar_paths)
|
Ok(calendar_paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse iCal datetime format and return NaiveDateTime + timezone info
|
||||||
|
/// According to RFC 5545: if no TZID parameter is provided, treat as UTC
|
||||||
|
fn parse_datetime_with_tz(
|
||||||
|
&self,
|
||||||
|
datetime_str: &str,
|
||||||
|
original_property: Option<&String>,
|
||||||
|
) -> Result<(chrono::NaiveDateTime, Option<String>), CalDAVError> {
|
||||||
|
// Extract timezone information from the original property if available
|
||||||
|
let mut timezone_id: Option<String> = None;
|
||||||
|
if let Some(prop) = original_property {
|
||||||
|
// Look for TZID parameter in the property
|
||||||
|
// Format: DTSTART;TZID=America/Denver:20231225T090000
|
||||||
|
if let Some(tzid_start) = prop.find("TZID=") {
|
||||||
|
let tzid_part = &prop[tzid_start + 5..];
|
||||||
|
if let Some(tzid_end) = tzid_part.find(':') {
|
||||||
|
timezone_id = Some(tzid_part[..tzid_end].to_string());
|
||||||
|
} else if let Some(tzid_end) = tzid_part.find(';') {
|
||||||
|
timezone_id = Some(tzid_part[..tzid_end].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the datetime string - remove any TZID prefix if present
|
||||||
|
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
|
||||||
|
|
||||||
|
// Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000"
|
||||||
|
let datetime_part = if let Some(colon_pos) = cleaned.find(':') {
|
||||||
|
&cleaned[colon_pos + 1..]
|
||||||
|
} else {
|
||||||
|
&cleaned
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try different parsing formats
|
||||||
|
let formats = [
|
||||||
|
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
||||||
|
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
||||||
|
"%Y%m%d", // Date only: 20231225
|
||||||
|
];
|
||||||
|
|
||||||
|
for format in &formats {
|
||||||
|
// Try parsing as UTC format (with Z suffix)
|
||||||
|
if datetime_part.ends_with('Z') {
|
||||||
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") {
|
||||||
|
// Z suffix means UTC, ignore any TZID parameter
|
||||||
|
return Ok((dt, Some("UTC".to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing with timezone offset (e.g., 20231225T120000-0500)
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") {
|
||||||
|
// Convert to naive UTC time and return UTC timezone
|
||||||
|
return Ok((dt.naive_utc(), Some("UTC".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00)
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") {
|
||||||
|
// Convert to naive UTC time and return UTC timezone
|
||||||
|
return Ok((dt.naive_utc(), Some("UTC".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z)
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") {
|
||||||
|
// Z suffix means UTC
|
||||||
|
return Ok((dt.naive_utc(), Some("UTC".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for date-only format (all-day events)
|
||||||
|
if *format == "%Y%m%d" {
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) {
|
||||||
|
// Convert date to midnight datetime for all-day events
|
||||||
|
let naive_dt = date.and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
let tz = timezone_id.unwrap_or_else(|| "UTC".to_string());
|
||||||
|
return Ok((naive_dt, Some(tz)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try parsing as naive datetime for time-based formats
|
||||||
|
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) {
|
||||||
|
// Per RFC 5545: if no TZID parameter is provided, treat as UTC
|
||||||
|
let tz = timezone_id.unwrap_or_else(|| "UTC".to_string());
|
||||||
|
|
||||||
|
// If it's UTC, the naive time is already correct
|
||||||
|
// If it's a local timezone, we store the naive time and the timezone ID
|
||||||
|
return Ok((naive_dt, Some(tz)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(CalDAVError::ParseError(format!(
|
||||||
|
"Could not parse datetime: {}",
|
||||||
|
datetime_str
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse iCal datetime format with timezone support
|
/// Parse iCal datetime format with timezone support
|
||||||
fn parse_datetime(
|
fn parse_datetime(
|
||||||
&self,
|
&self,
|
||||||
@@ -1207,8 +1306,19 @@ impl CalDAVClient {
|
|||||||
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
||||||
let format_datetime =
|
let format_datetime =
|
||||||
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
|
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
|
||||||
|
let format_datetime_naive =
|
||||||
|
|dt: &chrono::NaiveDateTime| -> String { dt.format("%Y%m%dT%H%M%S").to_string() };
|
||||||
|
|
||||||
let format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
|
let _format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
|
||||||
|
|
||||||
|
// Format NaiveDateTime for iCal (local time without Z suffix)
|
||||||
|
let format_naive_datetime = |dt: &chrono::NaiveDateTime| -> String {
|
||||||
|
dt.format("%Y%m%dT%H%M%S").to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let format_naive_date = |dt: &chrono::NaiveDateTime| -> String {
|
||||||
|
dt.format("%Y%m%d").to_string()
|
||||||
|
};
|
||||||
|
|
||||||
// Start building the iCal event
|
// Start building the iCal event
|
||||||
let mut ical = String::new();
|
let mut ical = String::new();
|
||||||
@@ -1225,15 +1335,77 @@ impl CalDAVClient {
|
|||||||
if event.all_day {
|
if event.all_day {
|
||||||
ical.push_str(&format!(
|
ical.push_str(&format!(
|
||||||
"DTSTART;VALUE=DATE:{}\r\n",
|
"DTSTART;VALUE=DATE:{}\r\n",
|
||||||
format_date(&event.dtstart)
|
format_naive_date(&event.dtstart)
|
||||||
));
|
));
|
||||||
if let Some(end) = &event.dtend {
|
if let Some(end) = &event.dtend {
|
||||||
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
|
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_naive_date(end)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.dtstart)));
|
// Include timezone information for non-all-day events per RFC 5545
|
||||||
|
if let Some(ref start_tzid) = event.dtstart_tzid {
|
||||||
|
if start_tzid == "UTC" {
|
||||||
|
// UTC events should use Z suffix format
|
||||||
|
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&event.dtstart)));
|
||||||
|
} else if start_tzid.starts_with('+') || start_tzid.starts_with('-') {
|
||||||
|
// Timezone offset format (e.g., "+05:00", "-04:00")
|
||||||
|
// Convert local time to UTC using the offset and use Z format
|
||||||
|
if let Ok(offset_hours) = start_tzid[1..3].parse::<i32>() {
|
||||||
|
let offset_minutes = start_tzid[4..6].parse::<i32>().unwrap_or(0);
|
||||||
|
let total_offset_minutes = if start_tzid.starts_with('+') {
|
||||||
|
offset_hours * 60 + offset_minutes
|
||||||
|
} else {
|
||||||
|
-(offset_hours * 60 + offset_minutes)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert local time to UTC by applying the inverse offset
|
||||||
|
// If timezone is +04:00 (local ahead of UTC), subtract to get UTC
|
||||||
|
// If timezone is -04:00 (local behind UTC), add to get UTC
|
||||||
|
let utc_time = event.dtstart - chrono::Duration::minutes(total_offset_minutes as i64);
|
||||||
|
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&utc_time)));
|
||||||
|
} else {
|
||||||
|
// Fallback to floating time if offset parsing fails
|
||||||
|
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
|
||||||
|
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", start_tzid, format_naive_datetime(&event.dtstart)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No timezone info - treat as floating local time per RFC 5545
|
||||||
|
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(end) = &event.dtend {
|
if let Some(end) = &event.dtend {
|
||||||
ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end)));
|
if let Some(ref end_tzid) = event.dtend_tzid {
|
||||||
|
if end_tzid == "UTC" {
|
||||||
|
// UTC events should use Z suffix format
|
||||||
|
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(end)));
|
||||||
|
} else if end_tzid.starts_with('+') || end_tzid.starts_with('-') {
|
||||||
|
// Timezone offset format (e.g., "+05:00", "-04:00")
|
||||||
|
// Convert local time to UTC using the offset and use Z format
|
||||||
|
if let Ok(offset_hours) = end_tzid[1..3].parse::<i32>() {
|
||||||
|
let offset_minutes = end_tzid[4..6].parse::<i32>().unwrap_or(0);
|
||||||
|
let total_offset_minutes = if end_tzid.starts_with('+') {
|
||||||
|
offset_hours * 60 + offset_minutes
|
||||||
|
} else {
|
||||||
|
-(offset_hours * 60 + offset_minutes)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert local time to UTC by subtracting the offset
|
||||||
|
let utc_time = *end - chrono::Duration::minutes(total_offset_minutes as i64);
|
||||||
|
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(&utc_time)));
|
||||||
|
} else {
|
||||||
|
// Fallback to floating time if offset parsing fails
|
||||||
|
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
|
||||||
|
ical.push_str(&format!("DTEND;TZID={}:{}\r\n", end_tzid, format_naive_datetime(end)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No timezone info - treat as floating local time per RFC 5545
|
||||||
|
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1289,7 +1461,18 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Creation and modification times
|
// Creation and modification times
|
||||||
if let Some(created) = &event.created {
|
if let Some(created) = &event.created {
|
||||||
ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created)));
|
if let Some(ref created_tzid) = event.created_tzid {
|
||||||
|
if created_tzid == "UTC" {
|
||||||
|
ical.push_str(&format!("CREATED:{}Z\r\n", format_datetime_naive(created)));
|
||||||
|
} else {
|
||||||
|
// Per RFC 5545, CREATED typically should be in UTC or floating time
|
||||||
|
// Treat non-UTC as floating time
|
||||||
|
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No timezone info - output as floating time per RFC 5545
|
||||||
|
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now)));
|
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now)));
|
||||||
@@ -1346,10 +1529,10 @@ impl CalDAVClient {
|
|||||||
if event.all_day {
|
if event.all_day {
|
||||||
ical.push_str(&format!(
|
ical.push_str(&format!(
|
||||||
"EXDATE;VALUE=DATE:{}\r\n",
|
"EXDATE;VALUE=DATE:{}\r\n",
|
||||||
format_date(exception_date)
|
format_naive_date(exception_date)
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date)));
|
ical.push_str(&format!("EXDATE:{}\r\n", format_naive_datetime(exception_date)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,10 +82,6 @@ pub async fn get_user_info(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
println!(
|
|
||||||
"✅ Authentication successful! Found {} calendars",
|
|
||||||
calendar_paths.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
let calendars: Vec<CalendarInfo> = calendar_paths
|
let calendars: Vec<CalendarInfo> = calendar_paths
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ pub async fn get_calendar_events(
|
|||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
println!("🔑 API call with password length: {}", password.len());
|
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state
|
let config = state
|
||||||
@@ -85,7 +84,7 @@ pub async fn get_calendar_events(
|
|||||||
} - chrono::Duration::days(1);
|
} - chrono::Duration::days(1);
|
||||||
|
|
||||||
all_events.retain(|event| {
|
all_events.retain(|event| {
|
||||||
let event_date = event.dtstart.date_naive();
|
let event_date = event.dtstart.date();
|
||||||
|
|
||||||
// For non-recurring events, check if the event date is within the month
|
// For non-recurring events, check if the event date is within the month
|
||||||
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
|
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
|
||||||
@@ -127,7 +126,6 @@ pub async fn get_calendar_events(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("📅 Returning {} events", all_events.len());
|
|
||||||
Ok(Json(all_events))
|
Ok(Json(all_events))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,26 +232,26 @@ pub async fn delete_event(
|
|||||||
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
||||||
// Recurring event - add EXDATE for this occurrence
|
// Recurring event - add EXDATE for this occurrence
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let exception_utc = if let Ok(date) =
|
let exception_datetime = if let Ok(date) =
|
||||||
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
{
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone) - convert to naive
|
||||||
date.with_timezone(&chrono::Utc)
|
date.naive_utc()
|
||||||
} else if let Ok(naive_date) =
|
} else if let Ok(naive_date) =
|
||||||
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
{
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap()
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut updated_event = event;
|
let mut updated_event = event;
|
||||||
updated_event.exdate.push(exception_utc);
|
updated_event.exdate.push(exception_datetime);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"🔄 Adding EXDATE {} to recurring event {}",
|
"🔄 Adding EXDATE {} to recurring event {}",
|
||||||
exception_utc.format("%Y%m%dT%H%M%SZ"),
|
exception_datetime.format("%Y%m%dT%H%M%S"),
|
||||||
updated_event.uid
|
updated_event.uid
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -453,12 +451,12 @@ pub async fn create_event(
|
|||||||
calendar_paths[0].clone()
|
calendar_paths[0].clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times as local times (no UTC conversion)
|
||||||
let start_datetime =
|
let start_datetime =
|
||||||
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// For all-day events, add one day to end date for RFC-5545 compliance
|
// For all-day events, add one day to end date for RFC-5545 compliance
|
||||||
@@ -594,9 +592,13 @@ pub async fn create_event(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the VEvent struct (RFC 5545 compliant)
|
// Create the VEvent struct (RFC 5545 compliant) with local times
|
||||||
let mut event = VEvent::new(uid, start_datetime);
|
let mut event = VEvent::new(uid, start_datetime);
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
|
|
||||||
|
// Set timezone information from client
|
||||||
|
event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
event.dtend_tzid = Some(request.timezone.clone());
|
||||||
event.summary = if request.title.trim().is_empty() {
|
event.summary = if request.title.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -757,12 +759,14 @@ pub async fn update_event(
|
|||||||
let (mut event, calendar_path, event_href) = found_event
|
let (mut event, calendar_path, event_href) = found_event
|
||||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times as local times (no UTC conversion)
|
||||||
|
println!("🕐 UPDATE: Received start_date: '{}', start_time: '{}', timezone: '{}'",
|
||||||
|
request.start_date, request.start_time, request.timezone);
|
||||||
let start_datetime =
|
let start_datetime =
|
||||||
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// For all-day events, add one day to end date for RFC-5545 compliance
|
// For all-day events, add one day to end date for RFC-5545 compliance
|
||||||
@@ -786,9 +790,11 @@ pub async fn update_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update event properties
|
// Update event properties with local times and timezone info
|
||||||
event.dtstart = start_datetime;
|
event.dtstart = start_datetime;
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
|
event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
event.dtend_tzid = Some(request.timezone.clone());
|
||||||
event.summary = if request.title.trim().is_empty() {
|
event.summary = if request.title.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -822,6 +828,99 @@ pub async fn update_event(
|
|||||||
|
|
||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
|
|
||||||
|
// Process recurrence information to set RRULE
|
||||||
|
println!("🔄 Processing recurrence: '{}'", request.recurrence);
|
||||||
|
println!("🔄 Recurrence days: {:?}", request.recurrence_days);
|
||||||
|
println!("🔄 Recurrence interval: {:?}", request.recurrence_interval);
|
||||||
|
println!("🔄 Recurrence count: {:?}", request.recurrence_count);
|
||||||
|
println!("🔄 Recurrence end date: {:?}", request.recurrence_end_date);
|
||||||
|
|
||||||
|
let rrule = if request.recurrence.starts_with("FREQ=") {
|
||||||
|
// Frontend sent a complete RRULE string, use it directly
|
||||||
|
if request.recurrence.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.recurrence.clone())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Parse recurrence type and build RRULE with all parameters
|
||||||
|
let base_rrule = match request.recurrence.to_uppercase().as_str() {
|
||||||
|
"DAILY" => Some("FREQ=DAILY".to_string()),
|
||||||
|
"WEEKLY" => {
|
||||||
|
// Handle weekly recurrence with optional BYDAY parameter
|
||||||
|
let mut rrule = "FREQ=WEEKLY".to_string();
|
||||||
|
|
||||||
|
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||||
|
if request.recurrence_days.len() == 7 {
|
||||||
|
let selected_days: Vec<&str> = request
|
||||||
|
.recurrence_days
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, &selected)| {
|
||||||
|
if selected {
|
||||||
|
Some(match i {
|
||||||
|
0 => "SU", // Sunday
|
||||||
|
1 => "MO", // Monday
|
||||||
|
2 => "TU", // Tuesday
|
||||||
|
3 => "WE", // Wednesday
|
||||||
|
4 => "TH", // Thursday
|
||||||
|
5 => "FR", // Friday
|
||||||
|
6 => "SA", // Saturday
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !selected_days.is_empty() {
|
||||||
|
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(rrule)
|
||||||
|
}
|
||||||
|
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||||
|
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||||
|
"NONE" | "" => None, // Clear any existing recurrence
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add INTERVAL, COUNT, and UNTIL parameters if specified
|
||||||
|
if let Some(mut rrule_string) = base_rrule {
|
||||||
|
// Add INTERVAL parameter (every N days/weeks/months/years)
|
||||||
|
if let Some(interval) = request.recurrence_interval {
|
||||||
|
if interval > 1 {
|
||||||
|
rrule_string = format!("{};INTERVAL={}", rrule_string, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add COUNT or UNTIL parameter (but not both - COUNT takes precedence)
|
||||||
|
if let Some(count) = request.recurrence_count {
|
||||||
|
rrule_string = format!("{};COUNT={}", rrule_string, count);
|
||||||
|
} else if let Some(end_date) = &request.recurrence_end_date {
|
||||||
|
// Convert YYYY-MM-DD to YYYYMMDD format for UNTIL
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
|
||||||
|
rrule_string = format!("{};UNTIL={}", rrule_string, date.format("%Y%m%d"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(rrule_string)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
event.rrule = rrule.clone();
|
||||||
|
println!("🔄 Set event RRULE to: {:?}", rrule);
|
||||||
|
|
||||||
|
if rrule.is_some() {
|
||||||
|
println!("✨ Converting singleton event to recurring series with RRULE: {}", rrule.as_ref().unwrap());
|
||||||
|
} else {
|
||||||
|
println!("📝 Event remains non-recurring (no RRULE set)");
|
||||||
|
}
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
println!(
|
println!(
|
||||||
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
||||||
@@ -840,33 +939,29 @@ pub async fn update_event(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_event_datetime(
|
fn parse_event_datetime_local(
|
||||||
date_str: &str,
|
date_str: &str,
|
||||||
time_str: &str,
|
time_str: &str,
|
||||||
all_day: bool,
|
all_day: bool,
|
||||||
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
) -> Result<chrono::NaiveDateTime, String> {
|
||||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||||
|
|
||||||
// Parse the date
|
// Parse the date
|
||||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||||
|
|
||||||
if all_day {
|
if all_day {
|
||||||
// For all-day events, use noon UTC to avoid timezone boundary issues
|
// For all-day events, use start of day
|
||||||
// This ensures the date remains correct when converted to any local timezone
|
|
||||||
let datetime = date
|
let datetime = date
|
||||||
.and_hms_opt(12, 0, 0)
|
.and_hms_opt(0, 0, 0)
|
||||||
.ok_or_else(|| "Failed to create noon datetime".to_string())?;
|
.ok_or_else(|| "Failed to create start-of-day datetime".to_string())?;
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
Ok(datetime)
|
||||||
} else {
|
} else {
|
||||||
// Parse the time
|
// Parse the time
|
||||||
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
||||||
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
||||||
|
|
||||||
// Combine date and time
|
// Combine date and time - now keeping as local time
|
||||||
let datetime = NaiveDateTime::new(date, time);
|
Ok(NaiveDateTime::new(date, time))
|
||||||
|
|
||||||
// Frontend now sends UTC times, so treat as UTC directly
|
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,17 +285,25 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
|
|||||||
|
|
||||||
let vevent = VEvent {
|
let vevent = VEvent {
|
||||||
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||||
dtstart,
|
dtstart: dtstart.naive_utc(),
|
||||||
dtend,
|
dtstart_tzid: None, // TODO: Parse timezone from ICS
|
||||||
|
dtend: dtend.map(|dt| dt.naive_utc()),
|
||||||
|
dtend_tzid: None, // TODO: Parse timezone from ICS
|
||||||
summary,
|
summary,
|
||||||
description,
|
description,
|
||||||
location,
|
location,
|
||||||
all_day,
|
all_day,
|
||||||
rrule,
|
rrule,
|
||||||
|
rdate: Vec::new(),
|
||||||
|
rdate_tzid: None,
|
||||||
exdate: Vec::new(), // External calendars don't need exception handling
|
exdate: Vec::new(), // External calendars don't need exception handling
|
||||||
|
exdate_tzid: None,
|
||||||
recurrence_id: None,
|
recurrence_id: None,
|
||||||
|
recurrence_id_tzid: None,
|
||||||
created: None,
|
created: None,
|
||||||
|
created_tzid: None,
|
||||||
last_modified: None,
|
last_modified: None,
|
||||||
|
last_modified_tzid: None,
|
||||||
dtstamp: Utc::now(),
|
dtstamp: Utc::now(),
|
||||||
sequence: Some(0),
|
sequence: Some(0),
|
||||||
status: None,
|
status: None,
|
||||||
@@ -313,7 +321,6 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
|
|||||||
class: None,
|
class: None,
|
||||||
contact: None,
|
contact: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
rdate: Vec::new(),
|
|
||||||
alarms: Vec::new(),
|
alarms: Vec::new(),
|
||||||
etag: None,
|
etag: None,
|
||||||
href: None,
|
href: None,
|
||||||
@@ -484,26 +491,18 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||||
|
|
||||||
for event in events.drain(..) {
|
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);
|
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut uid_deduplicated_events = Vec::new();
|
let mut uid_deduplicated_events = Vec::new();
|
||||||
|
|
||||||
for (uid, mut events_with_uid) in uid_groups.drain() {
|
for (_uid, mut events_with_uid) in uid_groups.drain() {
|
||||||
if events_with_uid.len() == 1 {
|
if events_with_uid.len() == 1 {
|
||||||
// Only one event with this UID, keep it
|
// Only one event with this UID, keep it
|
||||||
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
|
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
|
||||||
} else {
|
} else {
|
||||||
// Multiple events with same UID - prefer recurring over non-recurring
|
// 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
|
// Sort by preference: recurring events first, then by completeness
|
||||||
events_with_uid.sort_by(|a, b| {
|
events_with_uid.sort_by(|a, b| {
|
||||||
@@ -522,10 +521,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
|
|
||||||
// Keep the first (preferred) event
|
// Keep the first (preferred) event
|
||||||
let preferred_event = events_with_uid.into_iter().next().unwrap();
|
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);
|
uid_deduplicated_events.push(preferred_event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -834,7 +829,7 @@ fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VE
|
|||||||
if rrule.contains("FREQ=DAILY") {
|
if rrule.contains("FREQ=DAILY") {
|
||||||
// Daily recurrence
|
// Daily recurrence
|
||||||
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
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();
|
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||||
|
|
||||||
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
||||||
// Check if times match (allowing for timezone differences within same day)
|
// Check if times match (allowing for timezone differences within same day)
|
||||||
@@ -845,7 +840,7 @@ fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VE
|
|||||||
} else if rrule.contains("FREQ=WEEKLY") {
|
} else if rrule.contains("FREQ=WEEKLY") {
|
||||||
// Weekly recurrence
|
// Weekly recurrence
|
||||||
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
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();
|
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||||
|
|
||||||
// First check if it's the same day of week and time
|
// First check if it's the same day of week and time
|
||||||
let recurring_weekday = recurring_event.dtstart.weekday();
|
let recurring_weekday = recurring_event.dtstart.weekday();
|
||||||
|
|||||||
@@ -14,6 +14,33 @@ use calendar_models::{EventClass, EventStatus, VEvent};
|
|||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
|
fn parse_event_datetime_local(
|
||||||
|
date_str: &str,
|
||||||
|
time_str: &str,
|
||||||
|
all_day: bool,
|
||||||
|
) -> Result<chrono::NaiveDateTime, String> {
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||||
|
|
||||||
|
// Parse the date
|
||||||
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||||
|
|
||||||
|
if all_day {
|
||||||
|
// For all-day events, use start of day
|
||||||
|
let datetime = date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
|
.ok_or_else(|| "Failed to create start-of-day datetime".to_string())?;
|
||||||
|
Ok(datetime)
|
||||||
|
} else {
|
||||||
|
// Parse the time
|
||||||
|
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
||||||
|
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
||||||
|
|
||||||
|
// Combine date and time - now keeping as local time
|
||||||
|
Ok(NaiveDateTime::new(date, time))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new recurring event series
|
/// Create a new recurring event series
|
||||||
pub async fn create_event_series(
|
pub async fn create_event_series(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -106,84 +133,29 @@ pub async fn create_event_series(
|
|||||||
|
|
||||||
println!("📅 Using calendar path: {}", calendar_path);
|
println!("📅 Using calendar path: {}", calendar_path);
|
||||||
|
|
||||||
// Parse datetime components
|
// Parse dates and times as local times (no UTC conversion)
|
||||||
let start_date =
|
let start_datetime = parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
|
||||||
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| {
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let (start_datetime, end_datetime) = if request.all_day {
|
let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
|
||||||
// For all-day events, use the dates as-is
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
let start_dt = start_date
|
|
||||||
.and_hms_opt(0, 0, 0)
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
|
||||||
|
|
||||||
let end_date = if !request.end_date.is_empty() {
|
// For all-day events, add one day to end date for RFC-5545 compliance
|
||||||
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| {
|
if request.all_day {
|
||||||
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
end_datetime = end_datetime + chrono::Duration::days(1);
|
||||||
})?
|
}
|
||||||
} else {
|
|
||||||
start_date
|
|
||||||
};
|
|
||||||
|
|
||||||
let end_dt = end_date
|
|
||||||
.and_hms_opt(23, 59, 59)
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
|
||||||
|
|
||||||
// Frontend now sends UTC times, so treat as UTC directly
|
|
||||||
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
|
||||||
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
|
||||||
|
|
||||||
(
|
|
||||||
start_local.with_timezone(&chrono::Utc),
|
|
||||||
end_local.with_timezone(&chrono::Utc),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Parse times for timed events
|
|
||||||
let start_time = if !request.start_time.is_empty() {
|
|
||||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
|
||||||
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
|
|
||||||
})?
|
|
||||||
} else {
|
|
||||||
chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM
|
|
||||||
};
|
|
||||||
|
|
||||||
let end_time = if !request.end_time.is_empty() {
|
|
||||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
|
|
||||||
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
|
|
||||||
})?
|
|
||||||
} else {
|
|
||||||
chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration
|
|
||||||
};
|
|
||||||
|
|
||||||
let start_dt = start_date.and_time(start_time);
|
|
||||||
let end_dt = if !request.end_date.is_empty() {
|
|
||||||
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
|
|
||||||
.map_err(|_| {
|
|
||||||
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
|
||||||
})?;
|
|
||||||
end_date.and_time(end_time)
|
|
||||||
} else {
|
|
||||||
start_date.and_time(end_time)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Frontend now sends UTC times, so treat as UTC directly
|
|
||||||
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
|
||||||
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
|
||||||
|
|
||||||
(
|
|
||||||
start_local.with_timezone(&chrono::Utc),
|
|
||||||
end_local.with_timezone(&chrono::Utc),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate a unique UID for the series
|
// Generate a unique UID for the series
|
||||||
let uid = format!("series-{}", uuid::Uuid::new_v4().to_string());
|
let uid = format!("series-{}", uuid::Uuid::new_v4().to_string());
|
||||||
|
|
||||||
// Create the VEvent for the series
|
// Create the VEvent for the series with local times
|
||||||
let mut event = VEvent::new(uid.clone(), start_datetime);
|
let mut event = VEvent::new(uid.clone(), start_datetime);
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.all_day = request.all_day; // Set the all_day flag properly
|
event.all_day = request.all_day; // Set the all_day flag properly
|
||||||
|
|
||||||
|
// Set timezone information from client
|
||||||
|
event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
event.dtend_tzid = Some(request.timezone.clone());
|
||||||
event.summary = if request.title.trim().is_empty() {
|
event.summary = if request.title.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -257,6 +229,8 @@ pub async fn update_event_series(
|
|||||||
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
|
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
|
||||||
request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date
|
request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date
|
||||||
);
|
);
|
||||||
|
println!("🕐 SERIES: Received start_date: '{}', start_time: '{}', timezone: '{}'",
|
||||||
|
request.start_date, request.start_time, request.timezone);
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
@@ -372,7 +346,7 @@ pub async fn update_event_series(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Parse datetime components for the update
|
// Parse datetime components for the update
|
||||||
let original_start_date = existing_event.dtstart.date_naive();
|
let original_start_date = existing_event.dtstart.date();
|
||||||
|
|
||||||
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
|
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
|
||||||
// For "all_in_series" updates, preserve the original series start date
|
// For "all_in_series" updates, preserve the original series start date
|
||||||
@@ -399,7 +373,7 @@ pub async fn update_event_series(
|
|||||||
// Calculate the duration from the original event
|
// Calculate the duration from the original event
|
||||||
let original_duration_days = existing_event
|
let original_duration_days = existing_event
|
||||||
.dtend
|
.dtend
|
||||||
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
|
.map(|end| (end.date() - existing_event.dtstart.date()).num_days())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
start_date + chrono::Duration::days(original_duration_days)
|
start_date + chrono::Duration::days(original_duration_days)
|
||||||
} else {
|
} else {
|
||||||
@@ -410,11 +384,8 @@ pub async fn update_event_series(
|
|||||||
.and_hms_opt(12, 0, 0)
|
.and_hms_opt(12, 0, 0)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||||
|
|
||||||
// For all-day events, use UTC directly (no local conversion needed)
|
// For all-day events, use local times directly
|
||||||
(
|
(start_dt, end_dt)
|
||||||
chrono::Utc.from_utc_datetime(&start_dt),
|
|
||||||
chrono::Utc.from_utc_datetime(&end_dt),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
let start_time = if !request.start_time.is_empty() {
|
let start_time = if !request.start_time.is_empty() {
|
||||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
||||||
@@ -445,17 +416,11 @@ pub async fn update_event_series(
|
|||||||
.dtend
|
.dtend
|
||||||
.map(|end| end - existing_event.dtstart)
|
.map(|end| end - existing_event.dtstart)
|
||||||
.unwrap_or_else(|| chrono::Duration::hours(1));
|
.unwrap_or_else(|| chrono::Duration::hours(1));
|
||||||
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
|
start_dt + original_duration
|
||||||
};
|
};
|
||||||
|
|
||||||
// Frontend now sends UTC times, so treat as UTC directly
|
// Frontend now sends local times, so use them directly
|
||||||
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
(start_dt, end_dt)
|
||||||
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
|
||||||
|
|
||||||
(
|
|
||||||
start_local.with_timezone(&chrono::Utc),
|
|
||||||
end_local.with_timezone(&chrono::Utc),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle different update scopes
|
// Handle different update scopes
|
||||||
@@ -702,8 +667,8 @@ fn build_series_rrule_with_freq(
|
|||||||
fn update_entire_series(
|
fn update_entire_series(
|
||||||
existing_event: &mut VEvent,
|
existing_event: &mut VEvent,
|
||||||
request: &UpdateEventSeriesRequest,
|
request: &UpdateEventSeriesRequest,
|
||||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
start_datetime: chrono::NaiveDateTime,
|
||||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
end_datetime: chrono::NaiveDateTime,
|
||||||
) -> Result<(VEvent, u32), ApiError> {
|
) -> Result<(VEvent, u32), ApiError> {
|
||||||
// Clone the existing event to preserve all metadata
|
// Clone the existing event to preserve all metadata
|
||||||
let mut updated_event = existing_event.clone();
|
let mut updated_event = existing_event.clone();
|
||||||
@@ -711,6 +676,8 @@ fn update_entire_series(
|
|||||||
// Update only the modified properties from the request
|
// Update only the modified properties from the request
|
||||||
updated_event.dtstart = start_datetime;
|
updated_event.dtstart = start_datetime;
|
||||||
updated_event.dtend = Some(end_datetime);
|
updated_event.dtend = Some(end_datetime);
|
||||||
|
updated_event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
updated_event.dtend_tzid = Some(request.timezone.clone());
|
||||||
updated_event.summary = if request.title.trim().is_empty() {
|
updated_event.summary = if request.title.trim().is_empty() {
|
||||||
existing_event.summary.clone() // Keep original if empty
|
existing_event.summary.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
@@ -743,8 +710,9 @@ fn update_entire_series(
|
|||||||
|
|
||||||
// Update timestamps
|
// Update timestamps
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
let now_naive = now.naive_utc();
|
||||||
updated_event.dtstamp = now;
|
updated_event.dtstamp = now;
|
||||||
updated_event.last_modified = Some(now);
|
updated_event.last_modified = Some(now_naive);
|
||||||
// Keep original created timestamp to preserve event history
|
// Keep original created timestamp to preserve event history
|
||||||
|
|
||||||
// Update RRULE if recurrence parameters are provided
|
// Update RRULE if recurrence parameters are provided
|
||||||
@@ -832,8 +800,8 @@ fn update_entire_series(
|
|||||||
async fn update_this_and_future(
|
async fn update_this_and_future(
|
||||||
existing_event: &mut VEvent,
|
existing_event: &mut VEvent,
|
||||||
request: &UpdateEventSeriesRequest,
|
request: &UpdateEventSeriesRequest,
|
||||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
start_datetime: chrono::NaiveDateTime,
|
||||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
end_datetime: chrono::NaiveDateTime,
|
||||||
client: &CalDAVClient,
|
client: &CalDAVClient,
|
||||||
calendar_path: &str,
|
calendar_path: &str,
|
||||||
) -> Result<(VEvent, u32), ApiError> {
|
) -> Result<(VEvent, u32), ApiError> {
|
||||||
@@ -881,6 +849,8 @@ async fn update_this_and_future(
|
|||||||
new_series.uid = new_series_uid.clone();
|
new_series.uid = new_series_uid.clone();
|
||||||
new_series.dtstart = start_datetime;
|
new_series.dtstart = start_datetime;
|
||||||
new_series.dtend = Some(end_datetime);
|
new_series.dtend = Some(end_datetime);
|
||||||
|
new_series.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
new_series.dtend_tzid = Some(request.timezone.clone());
|
||||||
new_series.summary = if request.title.trim().is_empty() {
|
new_series.summary = if request.title.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -913,9 +883,10 @@ async fn update_this_and_future(
|
|||||||
|
|
||||||
// Update timestamps
|
// Update timestamps
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
let now_naive = now.naive_utc();
|
||||||
new_series.dtstamp = now;
|
new_series.dtstamp = now;
|
||||||
new_series.created = Some(now);
|
new_series.created = Some(now_naive);
|
||||||
new_series.last_modified = Some(now);
|
new_series.last_modified = Some(now_naive);
|
||||||
new_series.href = None; // Will be set when created
|
new_series.href = None; // Will be set when created
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
@@ -943,8 +914,8 @@ async fn update_this_and_future(
|
|||||||
async fn update_single_occurrence(
|
async fn update_single_occurrence(
|
||||||
existing_event: &mut VEvent,
|
existing_event: &mut VEvent,
|
||||||
request: &UpdateEventSeriesRequest,
|
request: &UpdateEventSeriesRequest,
|
||||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
start_datetime: chrono::NaiveDateTime,
|
||||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
end_datetime: chrono::NaiveDateTime,
|
||||||
client: &CalDAVClient,
|
client: &CalDAVClient,
|
||||||
calendar_path: &str,
|
calendar_path: &str,
|
||||||
_original_event_href: &str,
|
_original_event_href: &str,
|
||||||
@@ -969,21 +940,20 @@ async fn update_single_occurrence(
|
|||||||
// Create the EXDATE datetime using the original event's time
|
// Create the EXDATE datetime using the original event's time
|
||||||
let original_time = existing_event.dtstart.time();
|
let original_time = existing_event.dtstart.time();
|
||||||
let exception_datetime = exception_date.and_time(original_time);
|
let exception_datetime = exception_date.and_time(original_time);
|
||||||
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
|
||||||
|
|
||||||
// Add the exception date to the original series
|
// Add the exception date to the original series
|
||||||
println!(
|
println!(
|
||||||
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
|
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
|
||||||
existing_event.exdate
|
existing_event.exdate
|
||||||
);
|
);
|
||||||
existing_event.exdate.push(exception_utc);
|
existing_event.exdate.push(exception_datetime);
|
||||||
println!(
|
println!(
|
||||||
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
|
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
|
||||||
existing_event.exdate
|
existing_event.exdate
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
"🚫 Added EXDATE for single occurrence modification: {}",
|
"🚫 Added EXDATE for single occurrence modification: {}",
|
||||||
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
exception_datetime.format("%Y-%m-%d %H:%M:%S")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create exception event by cloning the existing event to preserve all metadata
|
// Create exception event by cloning the existing event to preserve all metadata
|
||||||
@@ -995,6 +965,8 @@ async fn update_single_occurrence(
|
|||||||
// Update the modified properties from the request
|
// Update the modified properties from the request
|
||||||
exception_event.dtstart = start_datetime;
|
exception_event.dtstart = start_datetime;
|
||||||
exception_event.dtend = Some(end_datetime);
|
exception_event.dtend = Some(end_datetime);
|
||||||
|
exception_event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
exception_event.dtend_tzid = Some(request.timezone.clone());
|
||||||
exception_event.summary = if request.title.trim().is_empty() {
|
exception_event.summary = if request.title.trim().is_empty() {
|
||||||
existing_event.summary.clone() // Keep original if empty
|
existing_event.summary.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
@@ -1027,8 +999,9 @@ async fn update_single_occurrence(
|
|||||||
|
|
||||||
// Update timestamps for the exception event
|
// Update timestamps for the exception event
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
let now_naive = now.naive_utc();
|
||||||
exception_event.dtstamp = now;
|
exception_event.dtstamp = now;
|
||||||
exception_event.last_modified = Some(now);
|
exception_event.last_modified = Some(now_naive);
|
||||||
// Keep original created timestamp to preserve event history
|
// Keep original created timestamp to preserve event history
|
||||||
|
|
||||||
// Set RECURRENCE-ID to point to the original occurrence
|
// Set RECURRENCE-ID to point to the original occurrence
|
||||||
@@ -1044,7 +1017,7 @@ async fn update_single_occurrence(
|
|||||||
|
|
||||||
println!(
|
println!(
|
||||||
"✨ Created exception event with RECURRENCE-ID: {}",
|
"✨ Created exception event with RECURRENCE-ID: {}",
|
||||||
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
exception_datetime.format("%Y-%m-%d %H:%M:%S")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create the exception event as a new event (original series will be updated by main handler)
|
// Create the exception event as a new event (original series will be updated by main handler)
|
||||||
@@ -1172,15 +1145,14 @@ async fn delete_single_occurrence(
|
|||||||
// Create the EXDATE datetime (use the same time as the original event)
|
// Create the EXDATE datetime (use the same time as the original event)
|
||||||
let original_time = existing_event.dtstart.time();
|
let original_time = existing_event.dtstart.time();
|
||||||
let exception_datetime = exception_date.and_time(original_time);
|
let exception_datetime = exception_date.and_time(original_time);
|
||||||
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
|
||||||
|
|
||||||
// Add the exception date to the event's EXDATE list
|
// Add the exception date to the event's EXDATE list
|
||||||
let mut updated_event = existing_event;
|
let mut updated_event = existing_event;
|
||||||
updated_event.exdate.push(exception_utc);
|
updated_event.exdate.push(exception_datetime);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"🗑️ Added EXDATE for single occurrence deletion: {}",
|
"🗑️ Added EXDATE for single occurrence deletion: {}",
|
||||||
exception_utc.format("%Y%m%dT%H%M%SZ")
|
exception_datetime.format("%Y%m%dT%H%M%S")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ pub struct CreateEventRequest {
|
|||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -146,8 +147,12 @@ pub struct UpdateEventRequest {
|
|||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
pub update_action: Option<String>, // "update_series" for recurring events
|
pub update_action: Option<String>, // "update_series" for recurring events
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
|
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
|
||||||
}
|
}
|
||||||
@@ -185,6 +190,7 @@ pub struct CreateEventSeriesRequest {
|
|||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
pub recurrence_count: Option<u32>, // Number of occurrences
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -227,6 +233,7 @@ pub struct UpdateEventSeriesRequest {
|
|||||||
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
||||||
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! VEvent - RFC 5545 compliant calendar event structure
|
//! VEvent - RFC 5545 compliant calendar event structure
|
||||||
|
|
||||||
use crate::common::*;
|
use crate::common::*;
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== VEVENT COMPONENT ====================
|
// ==================== VEVENT COMPONENT ====================
|
||||||
@@ -9,12 +9,14 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VEvent {
|
pub struct VEvent {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED (always UTC)
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
pub dtstart: NaiveDateTime, // Start date-time (DTSTART) - REQUIRED (local time)
|
||||||
|
pub dtstart_tzid: Option<String>, // Timezone ID for DTSTART (TZID parameter)
|
||||||
|
|
||||||
// Optional properties (commonly used)
|
// Optional properties (commonly used)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<NaiveDateTime>, // End date-time (DTEND) (local time)
|
||||||
|
pub dtend_tzid: Option<String>, // Timezone ID for DTEND (TZID parameter)
|
||||||
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
@@ -43,14 +45,19 @@ pub struct VEvent {
|
|||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<NaiveDateTime>, // Creation time (CREATED) (local time)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub created_tzid: Option<String>, // Timezone ID for CREATED
|
||||||
|
pub last_modified: Option<NaiveDateTime>, // Last modified (LAST-MODIFIED) (local time)
|
||||||
|
pub last_modified_tzid: Option<String>, // Timezone ID for LAST-MODIFIED
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<NaiveDateTime>, // Recurrence dates (RDATE) (local time)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub rdate_tzid: Option<String>, // Timezone ID for RDATE
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub exdate: Vec<NaiveDateTime>, // Exception dates (EXDATE) (local time)
|
||||||
|
pub exdate_tzid: Option<String>, // Timezone ID for EXDATE
|
||||||
|
pub recurrence_id: Option<NaiveDateTime>, // Recurrence ID (RECURRENCE-ID) (local time)
|
||||||
|
pub recurrence_id_tzid: Option<String>, // Timezone ID for RECURRENCE-ID
|
||||||
|
|
||||||
// Alarms and attachments
|
// Alarms and attachments
|
||||||
pub alarms: Vec<VAlarm>, // VALARM components
|
pub alarms: Vec<VAlarm>, // VALARM components
|
||||||
@@ -64,13 +71,15 @@ pub struct VEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VEvent {
|
impl VEvent {
|
||||||
/// Create a new VEvent with required fields
|
/// Create a new VEvent with required fields (local time)
|
||||||
pub fn new(uid: String, dtstart: DateTime<Utc>) -> Self {
|
pub fn new(uid: String, dtstart: NaiveDateTime) -> Self {
|
||||||
Self {
|
Self {
|
||||||
dtstamp: Utc::now(),
|
dtstamp: Utc::now(),
|
||||||
uid,
|
uid,
|
||||||
dtstart,
|
dtstart,
|
||||||
|
dtstart_tzid: None,
|
||||||
dtend: None,
|
dtend: None,
|
||||||
|
dtend_tzid: None,
|
||||||
duration: None,
|
duration: None,
|
||||||
summary: None,
|
summary: None,
|
||||||
description: None,
|
description: None,
|
||||||
@@ -89,12 +98,17 @@ impl VEvent {
|
|||||||
url: None,
|
url: None,
|
||||||
geo: None,
|
geo: None,
|
||||||
sequence: None,
|
sequence: None,
|
||||||
created: Some(Utc::now()),
|
created: Some(chrono::Local::now().naive_local()),
|
||||||
last_modified: Some(Utc::now()),
|
created_tzid: None,
|
||||||
|
last_modified: Some(chrono::Local::now().naive_local()),
|
||||||
|
last_modified_tzid: None,
|
||||||
rrule: None,
|
rrule: None,
|
||||||
rdate: Vec::new(),
|
rdate: Vec::new(),
|
||||||
|
rdate_tzid: None,
|
||||||
exdate: Vec::new(),
|
exdate: Vec::new(),
|
||||||
|
exdate_tzid: None,
|
||||||
recurrence_id: None,
|
recurrence_id: None,
|
||||||
|
recurrence_id_tzid: None,
|
||||||
alarms: Vec::new(),
|
alarms: Vec::new(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
etag: None,
|
etag: None,
|
||||||
@@ -105,7 +119,7 @@ impl VEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Helper method to get effective end time (dtend or dtstart + duration)
|
/// Helper method to get effective end time (dtend or dtstart + duration)
|
||||||
pub fn get_end_time(&self) -> DateTime<Utc> {
|
pub fn get_end_time(&self) -> NaiveDateTime {
|
||||||
if let Some(dtend) = self.dtend {
|
if let Some(dtend) = self.dtend {
|
||||||
dtend
|
dtend
|
||||||
} else if let Some(duration) = self.duration {
|
} else if let Some(duration) = self.duration {
|
||||||
@@ -136,7 +150,7 @@ impl VEvent {
|
|||||||
|
|
||||||
/// Helper method to get start date for UI compatibility
|
/// Helper method to get start date for UI compatibility
|
||||||
pub fn get_date(&self) -> chrono::NaiveDate {
|
pub fn get_date(&self) -> chrono::NaiveDate {
|
||||||
self.dtstart.date_naive()
|
self.dtstart.date()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if event is recurring
|
/// Check if event is recurring
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ pub fn App() -> Html {
|
|||||||
match auth_service.verify_token(&stored_token).await {
|
match auth_service.verify_token(&stored_token).await {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
// Token is valid, set it
|
// Token is valid, set it
|
||||||
web_sys::console::log_1(&"✅ Stored auth token is valid".into());
|
|
||||||
auth_token.set(Some(stored_token));
|
auth_token.set(Some(stored_token));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -723,8 +722,12 @@ pub fn App() -> Html {
|
|||||||
crate::components::event_form::RecurrenceType::Monthly |
|
crate::components::event_form::RecurrenceType::Monthly |
|
||||||
crate::components::event_form::RecurrenceType::Yearly);
|
crate::components::event_form::RecurrenceType::Yearly);
|
||||||
|
|
||||||
|
web_sys::console::log_1(&format!("🐛 FRONTEND DEBUG: is_recurring={}, edit_scope={:?}, original_uid={:?}",
|
||||||
|
is_recurring, event_data_for_update.edit_scope, event_data_for_update.original_uid).into());
|
||||||
|
|
||||||
let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() {
|
let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() {
|
||||||
// Use series update endpoint for recurring events
|
// Only use series endpoint for existing recurring events being edited
|
||||||
|
// Singleton→series conversion should use regular update_event endpoint
|
||||||
let edit_action = event_data_for_update.edit_scope.unwrap();
|
let edit_action = event_data_for_update.edit_scope.unwrap();
|
||||||
let scope = match edit_action {
|
let scope = match edit_action {
|
||||||
crate::components::EditAction::EditAll => "all_in_series".to_string(),
|
crate::components::EditAction::EditAll => "all_in_series".to_string(),
|
||||||
@@ -754,11 +757,13 @@ pub fn App() -> Html {
|
|||||||
params.14, // reminder
|
params.14, // reminder
|
||||||
params.15, // recurrence
|
params.15, // recurrence
|
||||||
params.16, // recurrence_days
|
params.16, // recurrence_days
|
||||||
|
params.17, // recurrence_interval
|
||||||
params.18, // recurrence_count
|
params.18, // recurrence_count
|
||||||
params.19, // recurrence_until
|
params.19, // recurrence_until
|
||||||
params.17, // calendar_path
|
params.20, // calendar_path
|
||||||
scope,
|
scope,
|
||||||
event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date
|
event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date
|
||||||
|
params.21, // timezone
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -785,10 +790,11 @@ pub fn App() -> Html {
|
|||||||
params.14, // reminder
|
params.14, // reminder
|
||||||
params.15, // recurrence
|
params.15, // recurrence
|
||||||
params.16, // recurrence_days
|
params.16, // recurrence_days
|
||||||
params.17, // calendar_path
|
params.17, // recurrence_interval
|
||||||
vec![], // exception_dates - empty for simple updates
|
params.18, // recurrence_count
|
||||||
None, // update_action - None for regular updates
|
params.19, // recurrence_until
|
||||||
None, // until_date - None for regular updates
|
params.20, // calendar_path
|
||||||
|
params.21, // timezone
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
@@ -872,9 +878,11 @@ pub fn App() -> Html {
|
|||||||
params.14, // reminder
|
params.14, // reminder
|
||||||
params.15, // recurrence
|
params.15, // recurrence
|
||||||
params.16, // recurrence_days
|
params.16, // recurrence_days
|
||||||
|
params.17, // recurrence_interval
|
||||||
params.18, // recurrence_count
|
params.18, // recurrence_count
|
||||||
params.19, // recurrence_until
|
params.19, // recurrence_until
|
||||||
params.17, // calendar_path
|
params.20, // calendar_path
|
||||||
|
params.21, // timezone
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
match create_result {
|
match create_result {
|
||||||
@@ -906,8 +914,8 @@ pub fn App() -> Html {
|
|||||||
original_event,
|
original_event,
|
||||||
new_start,
|
new_start,
|
||||||
new_end,
|
new_end,
|
||||||
preserve_rrule,
|
_preserve_rrule,
|
||||||
until_date,
|
_until_date,
|
||||||
update_scope,
|
update_scope,
|
||||||
occurrence_date,
|
occurrence_date,
|
||||||
): (
|
): (
|
||||||
@@ -915,7 +923,7 @@ pub fn App() -> Html {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)| {
|
)| {
|
||||||
@@ -954,30 +962,13 @@ pub fn App() -> Html {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert local naive datetime to UTC before sending to backend
|
|
||||||
use chrono::TimeZone;
|
|
||||||
let local_tz = chrono::Local;
|
|
||||||
|
|
||||||
let start_utc = local_tz.from_local_datetime(&new_start)
|
|
||||||
.single()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
// Fallback for ambiguous times (DST transitions)
|
|
||||||
local_tz.from_local_datetime(&new_start).earliest().unwrap()
|
|
||||||
})
|
|
||||||
.with_timezone(&chrono::Utc);
|
|
||||||
|
|
||||||
let end_utc = local_tz.from_local_datetime(&new_end)
|
|
||||||
.single()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
// Fallback for ambiguous times (DST transitions)
|
|
||||||
local_tz.from_local_datetime(&new_end).earliest().unwrap()
|
|
||||||
})
|
|
||||||
.with_timezone(&chrono::Utc);
|
|
||||||
|
|
||||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
// Send local times to backend, which will handle timezone conversion
|
||||||
let start_time = start_utc.format("%H:%M").to_string();
|
let start_date = new_start.format("%Y-%m-%d").to_string();
|
||||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
let start_time = new_start.format("%H:%M").to_string();
|
||||||
let end_time = end_utc.format("%H:%M").to_string();
|
let end_date = new_end.format("%Y-%m-%d").to_string();
|
||||||
|
let end_time = new_end.format("%H:%M").to_string();
|
||||||
|
|
||||||
// Convert existing event data to string formats for the API
|
// Convert existing event data to string formats for the API
|
||||||
let status_str = match original_event.status {
|
let status_str = match original_event.status {
|
||||||
@@ -1056,12 +1047,21 @@ pub fn App() -> Html {
|
|||||||
original_event.categories.join(","),
|
original_event.categories.join(","),
|
||||||
reminder_str.clone(),
|
reminder_str.clone(),
|
||||||
recurrence_str.clone(),
|
recurrence_str.clone(),
|
||||||
vec![false; 7],
|
vec![false; 7], // recurrence_days
|
||||||
None,
|
1, // recurrence_interval - default for drag-and-drop
|
||||||
None,
|
None, // recurrence_count
|
||||||
original_event.calendar_path.clone(),
|
None, // recurrence_until
|
||||||
scope.clone(),
|
original_event.calendar_path.clone(), // calendar_path
|
||||||
occurrence_date,
|
scope.clone(), // update_scope
|
||||||
|
occurrence_date, // occurrence_date
|
||||||
|
{
|
||||||
|
// Get timezone offset
|
||||||
|
let date = js_sys::Date::new_0();
|
||||||
|
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
|
||||||
|
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
|
||||||
|
let minutes = (timezone_offset as i32).abs() % 60;
|
||||||
|
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
|
||||||
|
}, // timezone
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
)
|
)
|
||||||
@@ -1105,14 +1105,18 @@ pub fn App() -> Html {
|
|||||||
reminder_str,
|
reminder_str,
|
||||||
recurrence_str,
|
recurrence_str,
|
||||||
recurrence_days,
|
recurrence_days,
|
||||||
|
1, // recurrence_interval - default to 1 for drag-and-drop
|
||||||
|
None, // recurrence_count - preserve existing
|
||||||
|
None, // recurrence_until - preserve existing
|
||||||
original_event.calendar_path,
|
original_event.calendar_path,
|
||||||
original_event.exdate.clone(),
|
{
|
||||||
if preserve_rrule {
|
// Get timezone offset
|
||||||
Some("update_series".to_string())
|
let date = js_sys::Date::new_0();
|
||||||
} else {
|
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
|
||||||
Some("this_and_future".to_string())
|
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
|
||||||
|
let minutes = (timezone_offset as i32).abs() % 60;
|
||||||
|
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
|
||||||
},
|
},
|
||||||
until_date,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
@@ -1193,10 +1197,6 @@ pub fn App() -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into(),
|
|
||||||
);
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -1474,7 +1474,7 @@ pub fn App() -> Html {
|
|||||||
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
let calendar_service = CalendarService::new();
|
let _calendar_service = CalendarService::new();
|
||||||
match CalendarService::get_external_calendars().await {
|
match CalendarService::get_external_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(calendars) => {
|
||||||
external_calendars.set(calendars);
|
external_calendars.set(calendars);
|
||||||
@@ -1597,7 +1597,7 @@ pub fn App() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get the occurrence date from the clicked event
|
// Get the occurrence date from the clicked event
|
||||||
let occurrence_date = Some(event.dtstart.date_naive().format("%Y-%m-%d").to_string());
|
let occurrence_date = Some(event.dtstart.date().format("%Y-%m-%d").to_string());
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔄 Delete action: {}", action_str).into());
|
web_sys::console::log_1(&format!("🔄 Delete action: {}", action_str).into());
|
||||||
web_sys::console::log_1(&format!("🔄 Event UID: {}", event.uid).into());
|
web_sys::console::log_1(&format!("🔄 Event UID: {}", event.uid).into());
|
||||||
@@ -1637,6 +1637,19 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
on_edit_singleton={Callback::from({
|
||||||
|
let event_context_menu_event = event_context_menu_event.clone();
|
||||||
|
let event_context_menu_open = event_context_menu_open.clone();
|
||||||
|
let create_event_modal_open = create_event_modal_open.clone();
|
||||||
|
let event_edit_scope = event_edit_scope.clone();
|
||||||
|
move |event: VEvent| {
|
||||||
|
// For singleton events, open edit modal WITHOUT setting edit_scope
|
||||||
|
event_context_menu_event.set(Some(event));
|
||||||
|
event_edit_scope.set(None); // Explicitly set to None for singleton edits
|
||||||
|
event_context_menu_open.set(false);
|
||||||
|
create_event_modal_open.set(true);
|
||||||
|
}
|
||||||
|
})}
|
||||||
on_view_details={Callback::from({
|
on_view_details={Callback::from({
|
||||||
let event_context_menu_open = event_context_menu_open.clone();
|
let event_context_menu_open = event_context_menu_open.clone();
|
||||||
let view_event_modal_open = view_event_modal_open.clone();
|
let view_event_modal_open = view_event_modal_open.clone();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub struct CalendarProps {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)>,
|
)>,
|
||||||
@@ -437,7 +437,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)| {
|
)| {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
|
|||||||
let on_external_success = on_external_success.clone();
|
let on_external_success = on_external_success.clone();
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
let calendar_service = CalendarService::new();
|
let _calendar_service = CalendarService::new();
|
||||||
|
|
||||||
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
||||||
Ok(calendar) => {
|
Ok(calendar) => {
|
||||||
|
|||||||
@@ -238,12 +238,11 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
|||||||
|
|
||||||
// Convert VEvent to EventCreationData for editing
|
// Convert VEvent to EventCreationData for editing
|
||||||
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
|
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
|
||||||
use chrono::Local;
|
|
||||||
|
|
||||||
// Convert start datetime from UTC to local
|
// VEvent fields are already local time (NaiveDateTime)
|
||||||
let start_local = event.dtstart.with_timezone(&Local).naive_local();
|
let start_local = event.dtstart;
|
||||||
let end_local = if let Some(dtend) = event.dtend {
|
let end_local = if let Some(dtend) = event.dtend {
|
||||||
dtend.with_timezone(&Local).naive_local()
|
dtend
|
||||||
} else {
|
} else {
|
||||||
// Default to 1 hour after start if no end time
|
// Default to 1 hour after start if no end time
|
||||||
start_local + chrono::Duration::hours(1)
|
start_local + chrono::Duration::hours(1)
|
||||||
@@ -292,8 +291,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
|
|||||||
|
|
||||||
// Recurrence - Parse RRULE if present
|
// Recurrence - Parse RRULE if present
|
||||||
recurrence: if let Some(ref rrule_str) = event.rrule {
|
recurrence: if let Some(ref rrule_str) = event.rrule {
|
||||||
|
web_sys::console::log_1(&format!("🐛 MODAL DEBUG: Event has RRULE: {}", rrule_str).into());
|
||||||
parse_rrule_frequency(rrule_str)
|
parse_rrule_frequency(rrule_str)
|
||||||
} else {
|
} else {
|
||||||
|
web_sys::console::log_1(&"🐛 MODAL DEBUG: Event has no RRULE (singleton)".into());
|
||||||
RecurrenceType::None
|
RecurrenceType::None
|
||||||
},
|
},
|
||||||
recurrence_interval: if let Some(ref rrule_str) = event.rrule {
|
recurrence_interval: if let Some(ref rrule_str) = event.rrule {
|
||||||
@@ -338,7 +339,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Edit tracking
|
// Edit tracking
|
||||||
edit_scope: None, // Will be set by the modal after creation
|
edit_scope: {
|
||||||
|
web_sys::console::log_1(&"🐛 MODAL DEBUG: Setting edit_scope to None for vevent_to_creation_data".into());
|
||||||
|
None // Will be set by the modal after creation
|
||||||
|
},
|
||||||
changed_fields: vec![],
|
changed_fields: vec![],
|
||||||
original_uid: Some(event.uid.clone()), // Preserve original UID for editing
|
original_uid: Some(event.uid.clone()), // Preserve original UID for editing
|
||||||
occurrence_date: Some(start_local.date()), // The occurrence date being edited
|
occurrence_date: Some(start_local.date()), // The occurrence date being edited
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub struct EventContextMenuProps {
|
|||||||
pub on_delete: Callback<DeleteAction>,
|
pub on_delete: Callback<DeleteAction>,
|
||||||
pub on_view_details: Callback<VEvent>,
|
pub on_view_details: Callback<VEvent>,
|
||||||
pub on_close: Callback<()>,
|
pub on_close: Callback<()>,
|
||||||
|
pub on_edit_singleton: Callback<VEvent>, // New callback for editing singleton events without scope
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(EventContextMenu)]
|
#[function_component(EventContextMenu)]
|
||||||
@@ -109,6 +110,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let create_singleton_edit_callback = {
|
||||||
|
let on_edit_singleton = props.on_edit_singleton.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let event = props.event.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(event) = &event {
|
||||||
|
on_edit_singleton.emit(event.clone());
|
||||||
|
}
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let create_delete_callback = |action: DeleteAction| {
|
let create_delete_callback = |action: DeleteAction| {
|
||||||
let on_delete = props.on_delete.clone();
|
let on_delete = props.on_delete.clone();
|
||||||
let on_close = props.on_close.clone();
|
let on_close = props.on_close.clone();
|
||||||
@@ -160,9 +173,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular single events - show edit option
|
// Regular single events - show edit option without setting edit scope
|
||||||
html! {
|
html! {
|
||||||
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
<div class="context-menu-item" onclick={create_singleton_edit_callback}>
|
||||||
{"Edit Event"}
|
{"Edit Event"}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,54 +148,45 @@ impl EventCreationData {
|
|||||||
String, // reminder
|
String, // reminder
|
||||||
String, // recurrence
|
String, // recurrence
|
||||||
Vec<bool>, // recurrence_days
|
Vec<bool>, // recurrence_days
|
||||||
Option<String>, // calendar_path
|
u32, // recurrence_interval
|
||||||
Option<u32>, // recurrence_count
|
Option<u32>, // recurrence_count
|
||||||
Option<String>, // recurrence_until
|
Option<String>, // recurrence_until
|
||||||
|
Option<String>, // calendar_path
|
||||||
|
String, // timezone
|
||||||
) {
|
) {
|
||||||
use chrono::{Local, TimeZone};
|
|
||||||
|
|
||||||
// Convert local date/time to UTC for backend
|
// Use local date/times and timezone - no UTC conversion
|
||||||
let (utc_start_date, utc_start_time, utc_end_date, utc_end_time) = if self.all_day {
|
let effective_end_date = if self.end_time == NaiveTime::from_hms_opt(0, 0, 0).unwrap() {
|
||||||
// For all-day events, just use the dates as-is (no time conversion needed)
|
// If end time is midnight (00:00), treat it as beginning of next day
|
||||||
(
|
self.end_date + chrono::Duration::days(1)
|
||||||
self.start_date.format("%Y-%m-%d").to_string(),
|
|
||||||
self.start_time.format("%H:%M").to_string(),
|
|
||||||
self.end_date.format("%Y-%m-%d").to_string(),
|
|
||||||
self.end_time.format("%H:%M").to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Convert local date/time to UTC
|
self.end_date
|
||||||
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single();
|
|
||||||
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single();
|
|
||||||
|
|
||||||
if let (Some(start_dt), Some(end_dt)) = (start_local, end_local) {
|
|
||||||
let start_utc = start_dt.with_timezone(&chrono::Utc);
|
|
||||||
let end_utc = end_dt.with_timezone(&chrono::Utc);
|
|
||||||
(
|
|
||||||
start_utc.format("%Y-%m-%d").to_string(),
|
|
||||||
start_utc.format("%H:%M").to_string(),
|
|
||||||
end_utc.format("%Y-%m-%d").to_string(),
|
|
||||||
end_utc.format("%H:%M").to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Fallback if timezone conversion fails - use local time as-is
|
|
||||||
web_sys::console::warn_1(&"⚠️ Failed to convert local time to UTC, using local time".into());
|
|
||||||
(
|
|
||||||
self.start_date.format("%Y-%m-%d").to_string(),
|
|
||||||
self.start_time.format("%H:%M").to_string(),
|
|
||||||
self.end_date.format("%Y-%m-%d").to_string(),
|
|
||||||
self.end_time.format("%H:%M").to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get the local timezone
|
||||||
|
let timezone = {
|
||||||
|
use js_sys::Date;
|
||||||
|
let date = Date::new_0();
|
||||||
|
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
|
||||||
|
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
|
||||||
|
let minutes = (timezone_offset as i32).abs() % 60;
|
||||||
|
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
|
||||||
|
};
|
||||||
|
|
||||||
|
let (start_date, start_time, end_date, end_time) = (
|
||||||
|
self.start_date.format("%Y-%m-%d").to_string(),
|
||||||
|
self.start_time.format("%H:%M").to_string(),
|
||||||
|
effective_end_date.format("%Y-%m-%d").to_string(),
|
||||||
|
self.end_time.format("%H:%M").to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
(
|
(
|
||||||
self.title.clone(),
|
self.title.clone(),
|
||||||
self.description.clone(),
|
self.description.clone(),
|
||||||
utc_start_date,
|
start_date,
|
||||||
utc_start_time,
|
start_time,
|
||||||
utc_end_date,
|
end_date,
|
||||||
utc_end_time,
|
end_time,
|
||||||
self.location.clone(),
|
self.location.clone(),
|
||||||
self.all_day,
|
self.all_day,
|
||||||
format!("{:?}", self.status).to_uppercase(),
|
format!("{:?}", self.status).to_uppercase(),
|
||||||
@@ -207,9 +198,11 @@ impl EventCreationData {
|
|||||||
format!("{:?}", self.reminder),
|
format!("{:?}", self.reminder),
|
||||||
format!("{:?}", self.recurrence),
|
format!("{:?}", self.recurrence),
|
||||||
self.recurrence_days.clone(),
|
self.recurrence_days.clone(),
|
||||||
self.selected_calendar.clone(),
|
self.recurrence_interval,
|
||||||
self.recurrence_count,
|
self.recurrence_count,
|
||||||
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
|
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||||
|
self.selected_calendar.clone(),
|
||||||
|
timezone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
@@ -213,7 +212,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
fn format_datetime(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
|
||||||
if all_day {
|
if all_day {
|
||||||
dt.format("%B %d, %Y").to_string()
|
dt.format("%B %d, %Y").to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -221,7 +220,7 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_datetime_end(dt: &DateTime<Utc>, all_day: bool) -> String {
|
fn format_datetime_end(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
|
||||||
if all_day {
|
if all_day {
|
||||||
// For all-day events, subtract one day from end date for display
|
// For all-day events, subtract one day from end date for display
|
||||||
// RFC-5545 uses exclusive end dates, but users expect inclusive display
|
// RFC-5545 uses exclusive end dates, but users expect inclusive display
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
// Remember checkboxes state - default to checked
|
// Remember checkboxes state - default to checked
|
||||||
let remember_server = use_state(|| true);
|
let remember_server = use_state(|| true);
|
||||||
let remember_username = use_state(|| true);
|
let remember_username = use_state(|| true);
|
||||||
|
|
||||||
|
// Password visibility toggle
|
||||||
|
let show_password = use_state(|| false);
|
||||||
|
|
||||||
let server_url_ref = use_node_ref();
|
let server_url_ref = use_node_ref();
|
||||||
let username_ref = use_node_ref();
|
let username_ref = use_node_ref();
|
||||||
@@ -30,17 +33,31 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
|
|
||||||
let on_server_url_change = {
|
let on_server_url_change = {
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
server_url.set(target.value());
|
let new_value = target.value();
|
||||||
|
server_url.set(new_value.clone());
|
||||||
|
|
||||||
|
// Save to localStorage immediately if remember is checked
|
||||||
|
if *remember_server {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", new_value);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_username_change = {
|
let on_username_change = {
|
||||||
let username = username.clone();
|
let username = username.clone();
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
username.set(target.value());
|
let new_value = target.value();
|
||||||
|
username.set(new_value.clone());
|
||||||
|
|
||||||
|
// Save to localStorage immediately if remember is checked
|
||||||
|
if *remember_username {
|
||||||
|
let _ = LocalStorage::set("remembered_username", new_value);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,6 +100,13 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_toggle_password_visibility = {
|
||||||
|
let show_password = show_password.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_password.set(!*show_password);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_submit = {
|
let on_submit = {
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
@@ -90,6 +114,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
let password = password.clone();
|
let password = password.clone();
|
||||||
let error_message = error_message.clone();
|
let error_message = error_message.clone();
|
||||||
let is_loading = is_loading.clone();
|
let is_loading = is_loading.clone();
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
let on_login = props.on_login.clone();
|
let on_login = props.on_login.clone();
|
||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
@@ -100,6 +126,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
let password = (*password).clone();
|
let password = (*password).clone();
|
||||||
let error_message = error_message.clone();
|
let error_message = error_message.clone();
|
||||||
let is_loading = is_loading.clone();
|
let is_loading = is_loading.clone();
|
||||||
|
let remember_server_value = *remember_server;
|
||||||
|
let remember_username_value = *remember_username;
|
||||||
let on_login = on_login.clone();
|
let on_login = on_login.clone();
|
||||||
|
|
||||||
// Basic client-side validation
|
// Basic client-side validation
|
||||||
@@ -140,6 +168,14 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save server URL and username to LocalStorage if remember checkboxes are checked
|
||||||
|
if remember_server_value {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", server_url.clone());
|
||||||
|
}
|
||||||
|
if remember_username_value {
|
||||||
|
let _ = LocalStorage::set("remembered_username", username.clone());
|
||||||
|
}
|
||||||
|
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
on_login.emit(token);
|
on_login.emit(token);
|
||||||
}
|
}
|
||||||
@@ -164,59 +200,79 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
<form onsubmit={on_submit}>
|
<form onsubmit={on_submit}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="server_url">{"CalDAV Server URL"}</label>
|
<label for="server_url">{"CalDAV Server URL"}</label>
|
||||||
<input
|
<div class="input-with-checkbox">
|
||||||
ref={server_url_ref}
|
|
||||||
type="text"
|
|
||||||
id="server_url"
|
|
||||||
placeholder="https://your-caldav-server.com/dav/"
|
|
||||||
value={(*server_url).clone()}
|
|
||||||
onchange={on_server_url_change}
|
|
||||||
disabled={*is_loading}
|
|
||||||
/>
|
|
||||||
<div class="remember-checkbox">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
ref={server_url_ref}
|
||||||
id="remember_server"
|
type="text"
|
||||||
checked={*remember_server}
|
id="server_url"
|
||||||
onchange={on_remember_server_change}
|
placeholder="https://your-caldav-server.com/dav/"
|
||||||
|
value={(*server_url).clone()}
|
||||||
|
onchange={on_server_url_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
tabindex="1"
|
||||||
/>
|
/>
|
||||||
<label for="remember_server">{"Remember server"}</label>
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_server">{"Remember"}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_server"
|
||||||
|
checked={*remember_server}
|
||||||
|
onchange={on_remember_server_change}
|
||||||
|
tabindex="4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">{"Username"}</label>
|
<label for="username">{"Username"}</label>
|
||||||
<input
|
<div class="input-with-checkbox">
|
||||||
ref={username_ref}
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
placeholder="Enter your username"
|
|
||||||
value={(*username).clone()}
|
|
||||||
onchange={on_username_change}
|
|
||||||
disabled={*is_loading}
|
|
||||||
/>
|
|
||||||
<div class="remember-checkbox">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
ref={username_ref}
|
||||||
id="remember_username"
|
type="text"
|
||||||
checked={*remember_username}
|
id="username"
|
||||||
onchange={on_remember_username_change}
|
placeholder="Enter your username"
|
||||||
|
value={(*username).clone()}
|
||||||
|
onchange={on_username_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
tabindex="2"
|
||||||
/>
|
/>
|
||||||
<label for="remember_username">{"Remember username"}</label>
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_username">{"Remember"}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_username"
|
||||||
|
checked={*remember_username}
|
||||||
|
onchange={on_remember_username_change}
|
||||||
|
tabindex="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">{"Password"}</label>
|
<label for="password">{"Password"}</label>
|
||||||
<input
|
<div class="password-input-container">
|
||||||
ref={password_ref}
|
<input
|
||||||
type="password"
|
ref={password_ref}
|
||||||
id="password"
|
type={if *show_password { "text" } else { "password" }}
|
||||||
placeholder="Enter your password"
|
id="password"
|
||||||
value={(*password).clone()}
|
placeholder="Enter your password"
|
||||||
onchange={on_password_change}
|
value={(*password).clone()}
|
||||||
disabled={*is_loading}
|
onchange={on_password_change}
|
||||||
/>
|
disabled={*is_loading}
|
||||||
|
tabindex="3"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="password-toggle-btn"
|
||||||
|
onclick={on_toggle_password_visibility}
|
||||||
|
tabindex="6"
|
||||||
|
title={if *show_password { "Hide password" } else { "Show password" }}
|
||||||
|
>
|
||||||
|
<i class={if *show_password { "fas fa-eye-slash" } else { "fas fa-eye" }}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
"#3B82F6".to_string()
|
"#3B82F6".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-grid">
|
<div class="calendar-grid">
|
||||||
// Weekday headers
|
// Weekday headers
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub struct RouteHandlerProps {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)>,
|
)>,
|
||||||
@@ -136,7 +136,7 @@ pub struct CalendarViewProps {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)>,
|
)>,
|
||||||
|
|||||||
@@ -2,17 +2,7 @@ use crate::components::CalendarListItem;
|
|||||||
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
use web_sys::HtmlSelectElement;
|
use web_sys::HtmlSelectElement;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
|
||||||
pub enum Route {
|
|
||||||
#[at("/")]
|
|
||||||
Home,
|
|
||||||
#[at("/login")]
|
|
||||||
Login,
|
|
||||||
#[at("/calendar")]
|
|
||||||
Calendar,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub enum ViewMode {
|
pub enum ViewMode {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ pub struct WeekViewProps {
|
|||||||
NaiveDateTime,
|
NaiveDateTime,
|
||||||
NaiveDateTime,
|
NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)>,
|
)>,
|
||||||
@@ -285,18 +285,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
// Calculate the day before this occurrence for UNTIL clause
|
// Calculate the day before this occurrence for UNTIL clause
|
||||||
let until_date =
|
let until_date =
|
||||||
edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
edit.event.dtstart.date() - chrono::Duration::days(1);
|
||||||
let until_datetime = until_date
|
let until_datetime = until_date
|
||||||
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
||||||
let until_utc =
|
let until_naive = until_datetime; // Use local time directly
|
||||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
|
||||||
until_datetime,
|
|
||||||
chrono::Utc,
|
|
||||||
);
|
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
||||||
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
until_naive.format("%Y-%m-%d %H:%M:%S"),
|
||||||
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
|
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S")).into());
|
||||||
|
|
||||||
// Critical: Use the dragged times (new_start/new_end) not the original series times
|
// Critical: Use the dragged times (new_start/new_end) not the original series times
|
||||||
// This ensures the new series reflects the user's drag operation
|
// This ensures the new series reflects the user's drag operation
|
||||||
@@ -317,7 +313,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
new_start, // Dragged start time for new series
|
new_start, // Dragged start time for new series
|
||||||
new_end, // Dragged end time for new series
|
new_end, // Dragged end time for new series
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
Some(until_utc), // UNTIL date for original series
|
Some(until_naive), // UNTIL date for original series
|
||||||
Some("this_and_future".to_string()), // Update scope
|
Some("this_and_future".to_string()), // Update scope
|
||||||
Some(occurrence_date), // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
@@ -352,6 +348,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="week-view-container">
|
<div class="week-view-container">
|
||||||
// Header with weekday names and dates
|
// Header with weekday names and dates
|
||||||
@@ -617,10 +614,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
// Keep the original end time
|
// Keep the original end time
|
||||||
let original_end = if let Some(end) = event.dtend {
|
let original_end = if let Some(end) = event.dtend {
|
||||||
end.with_timezone(&chrono::Local).naive_local()
|
end } else {
|
||||||
} else {
|
|
||||||
// If no end time, use start time + 1 hour as default
|
// If no end time, use start time + 1 hour as default
|
||||||
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
event.dtstart + chrono::Duration::hours(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
||||||
@@ -651,8 +647,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Calculate new end time based on drag position
|
// Calculate new end time based on drag position
|
||||||
let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
|
let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
|
||||||
|
|
||||||
// Keep the original start time
|
// Keep the original start time (already local)
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart;
|
||||||
|
|
||||||
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
|
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
|
||||||
|
|
||||||
@@ -827,9 +823,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let time_display = if event.all_day {
|
let time_display = if event.all_day {
|
||||||
"All Day".to_string()
|
"All Day".to_string()
|
||||||
} else {
|
} else {
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart;
|
||||||
if let Some(end) = event.dtend {
|
if let Some(end) = event.dtend {
|
||||||
let local_end = end.with_timezone(&Local);
|
let local_end = end;
|
||||||
|
|
||||||
// Check if both times are in same AM/PM period to avoid redundancy
|
// Check if both times are in same AM/PM period to avoid redundancy
|
||||||
let start_is_am = local_start.hour() < 12;
|
let start_is_am = local_start.hour() < 12;
|
||||||
@@ -1056,14 +1052,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Show the event being resized from the start
|
// Show the event being resized from the start
|
||||||
let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
|
let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
|
||||||
let original_end = if let Some(end) = event.dtend {
|
let original_end = if let Some(end) = event.dtend {
|
||||||
end.with_timezone(&chrono::Local).naive_local()
|
end } else {
|
||||||
} else {
|
event.dtstart + chrono::Duration::hours(1)
|
||||||
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||||
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
let original_duration = original_end.signed_duration_since(event.dtstart);
|
||||||
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
||||||
|
|
||||||
let new_start_pixels = drag.current_y;
|
let new_start_pixels = drag.current_y;
|
||||||
@@ -1089,7 +1084,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
DragType::ResizeEventEnd(event) => {
|
DragType::ResizeEventEnd(event) => {
|
||||||
// Show the event being resized from the end
|
// Show the event being resized from the end
|
||||||
let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
|
let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart;
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||||
@@ -1227,17 +1222,14 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
|
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
|
||||||
// Convert UTC times to local time for display
|
// Events are already in local time
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart;
|
||||||
let event_date = local_start.date_naive();
|
|
||||||
|
|
||||||
// Position events based on when they appear in local time, not their original date
|
|
||||||
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
|
|
||||||
// but should still display on Sunday's column since that's when the user sees it
|
|
||||||
let should_display_here = event_date == date ||
|
|
||||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
|
|
||||||
|
|
||||||
if !should_display_here {
|
// Events should display based on their local date, since we now store proper UTC times
|
||||||
|
// Convert the UTC stored time back to local time to determine display date
|
||||||
|
let event_date = local_start.date();
|
||||||
|
|
||||||
|
if event_date != date {
|
||||||
return (0.0, 0.0, false); // Event not on this date
|
return (0.0, 0.0, false); // Event not on this date
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1266,8 +1258,8 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
|
|||||||
|
|
||||||
// Calculate duration and height
|
// Calculate duration and height
|
||||||
let duration_pixels = if let Some(end) = event.dtend {
|
let duration_pixels = if let Some(end) = event.dtend {
|
||||||
let local_end = end.with_timezone(&Local);
|
let local_end = end;
|
||||||
let end_date = local_end.date_naive();
|
let end_date = local_end.date();
|
||||||
|
|
||||||
// Handle events that span multiple days by capping at midnight
|
// Handle events that span multiple days by capping at midnight
|
||||||
if end_date > date {
|
if end_date > date {
|
||||||
@@ -1294,16 +1286,16 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
|
let start1 = event1.dtstart;
|
||||||
let end1 = if let Some(end) = event1.dtend {
|
let end1 = if let Some(end) = event1.dtend {
|
||||||
end.with_timezone(&Local).naive_local()
|
end
|
||||||
} else {
|
} else {
|
||||||
start1 + chrono::Duration::hours(1) // Default 1 hour duration
|
start1 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||||
};
|
};
|
||||||
|
|
||||||
let start2 = event2.dtstart.with_timezone(&Local).naive_local();
|
let start2 = event2.dtstart;
|
||||||
let end2 = if let Some(end) = event2.dtend {
|
let end2 = if let Some(end) = event2.dtend {
|
||||||
end.with_timezone(&Local).naive_local()
|
end
|
||||||
} else {
|
} else {
|
||||||
start2 + chrono::Duration::hours(1) // Default 1 hour duration
|
start2 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||||
};
|
};
|
||||||
@@ -1325,8 +1317,8 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
|
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart;
|
||||||
let event_date = local_start.date_naive();
|
let event_date = local_start.date();
|
||||||
if event_date == date ||
|
if event_date == date ||
|
||||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
|
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
|
||||||
Some((idx, event))
|
Some((idx, event))
|
||||||
@@ -1337,7 +1329,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by start time
|
// Sort by start time
|
||||||
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local());
|
day_events.sort_by_key(|(_, event)| event.dtstart);
|
||||||
|
|
||||||
// For each event, find all events it overlaps with
|
// For each event, find all events it overlaps with
|
||||||
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
|
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
|
||||||
@@ -1362,7 +1354,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
|
|||||||
} else {
|
} else {
|
||||||
// This event overlaps - we need to calculate column layout
|
// This event overlaps - we need to calculate column layout
|
||||||
// Sort the overlapping group by start time
|
// Sort the overlapping group by start time
|
||||||
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart.with_timezone(&Local).naive_local());
|
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart);
|
||||||
|
|
||||||
// Assign columns using a greedy algorithm
|
// Assign columns using a greedy algorithm
|
||||||
let mut columns: Vec<Vec<usize>> = Vec::new();
|
let mut columns: Vec<Vec<usize>> = Vec::new();
|
||||||
@@ -1410,19 +1402,19 @@ fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool {
|
|||||||
let start_date = if event.all_day {
|
let start_date = if event.all_day {
|
||||||
// For all-day events, extract date directly from UTC without timezone conversion
|
// For all-day events, extract date directly from UTC without timezone conversion
|
||||||
// since all-day events are stored at noon UTC to avoid timezone boundary issues
|
// since all-day events are stored at noon UTC to avoid timezone boundary issues
|
||||||
event.dtstart.date_naive()
|
event.dtstart.date()
|
||||||
} else {
|
} else {
|
||||||
event.dtstart.with_timezone(&Local).date_naive()
|
event.dtstart.date()
|
||||||
};
|
};
|
||||||
|
|
||||||
let end_date = if let Some(dtend) = event.dtend {
|
let end_date = if let Some(dtend) = event.dtend {
|
||||||
if event.all_day {
|
if event.all_day {
|
||||||
// For all-day events, dtend is set to the day after the last day (RFC 5545)
|
// For all-day events, dtend is set to the day after the last day (RFC 5545)
|
||||||
// Extract date directly from UTC and subtract a day to get actual last day
|
// Extract date directly from UTC and subtract a day to get actual last day
|
||||||
dtend.date_naive() - chrono::Duration::days(1)
|
dtend.date() - chrono::Duration::days(1)
|
||||||
} else {
|
} else {
|
||||||
// For timed events, use timezone conversion
|
// For timed events, use timezone conversion
|
||||||
dtend.with_timezone(&Local).date_naive()
|
dtend.date()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single day event
|
// Single day event
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -247,6 +247,8 @@ impl CalendarService {
|
|||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
let events: Vec<CalendarEvent> = serde_json::from_str(&text_string)
|
let events: Vec<CalendarEvent> = serde_json::from_str(&text_string)
|
||||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||||
|
|
||||||
|
|
||||||
Ok(events)
|
Ok(events)
|
||||||
} else {
|
} else {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
@@ -275,47 +277,66 @@ impl CalendarService {
|
|||||||
grouped
|
grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert UTC events to local timezone for display
|
||||||
|
fn convert_utc_to_local(mut event: VEvent) -> VEvent {
|
||||||
|
// All-day events should not have timezone conversions applied
|
||||||
|
if event.all_day {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event times are in UTC (legacy events from before timezone migration)
|
||||||
|
let is_utc_event = event.dtstart_tzid.as_ref().map_or(true, |tz| tz == "UTC");
|
||||||
|
|
||||||
|
if is_utc_event {
|
||||||
|
|
||||||
|
// Get current timezone offset (convert from UTC to local)
|
||||||
|
let date = js_sys::Date::new_0();
|
||||||
|
let timezone_offset_minutes = date.get_timezone_offset() as i32;
|
||||||
|
|
||||||
|
// Convert start time from UTC to local
|
||||||
|
// getTimezoneOffset() returns minutes UTC is ahead of local time
|
||||||
|
// To convert UTC to local, we subtract the offset (add negative offset)
|
||||||
|
let local_start = event.dtstart + chrono::Duration::minutes(-timezone_offset_minutes as i64);
|
||||||
|
event.dtstart = local_start;
|
||||||
|
event.dtstart_tzid = None; // Clear UTC timezone indicator
|
||||||
|
|
||||||
|
// Convert end time if present
|
||||||
|
if let Some(end_utc) = event.dtend {
|
||||||
|
let local_end = end_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64);
|
||||||
|
event.dtend = Some(local_end);
|
||||||
|
event.dtend_tzid = None; // Clear UTC timezone indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert created/modified times if present
|
||||||
|
if let Some(created_utc) = event.created {
|
||||||
|
event.created = Some(created_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64));
|
||||||
|
event.created_tzid = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(modified_utc) = event.last_modified {
|
||||||
|
event.last_modified = Some(modified_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64));
|
||||||
|
event.last_modified_tzid = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
/// Expand recurring events using VEvent (RFC 5545 compliant)
|
/// Expand recurring events using VEvent (RFC 5545 compliant)
|
||||||
pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
let mut expanded_events = Vec::new();
|
let mut expanded_events = Vec::new();
|
||||||
let today = chrono::Utc::now().date_naive();
|
let today = chrono::Local::now().date_naive();
|
||||||
let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events)
|
let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events)
|
||||||
let end_range = today + Duration::days(36500); // Show next 100 years
|
let end_range = today + Duration::days(36500); // Show next 100 years
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
|
// Convert UTC events to local time for proper display
|
||||||
|
let event = Self::convert_utc_to_local(event);
|
||||||
if let Some(ref rrule) = event.rrule {
|
if let Some(ref rrule) = event.rrule {
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"📅 Processing recurring VEvent '{}' with RRULE: {}",
|
|
||||||
event.summary.as_deref().unwrap_or("Untitled"),
|
|
||||||
rrule
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log if event has exception dates
|
|
||||||
if !event.exdate.is_empty() {
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"📅 VEvent '{}' has {} exception dates: {:?}",
|
|
||||||
event.summary.as_deref().unwrap_or("Untitled"),
|
|
||||||
event.exdate.len(),
|
|
||||||
event.exdate
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate occurrences for recurring events using VEvent
|
// Generate occurrences for recurring events using VEvent
|
||||||
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
|
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"📅 Generated {} occurrences for VEvent '{}'",
|
|
||||||
occurrences.len(),
|
|
||||||
event.summary.as_deref().unwrap_or("Untitled")
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
expanded_events.extend(occurrences);
|
expanded_events.extend(occurrences);
|
||||||
} else {
|
} else {
|
||||||
// Non-recurring event - add as-is
|
// Non-recurring event - add as-is
|
||||||
@@ -337,7 +358,6 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Parse RRULE components
|
// Parse RRULE components
|
||||||
let rrule_upper = rrule.to_uppercase();
|
let rrule_upper = rrule.to_uppercase();
|
||||||
web_sys::console::log_1(&format!("🔄 Parsing RRULE: {}", rrule_upper).into());
|
|
||||||
|
|
||||||
let components: HashMap<String, String> = rrule_upper
|
let components: HashMap<String, String> = rrule_upper
|
||||||
.split(';')
|
.split(';')
|
||||||
@@ -372,17 +392,18 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Get UNTIL date if specified
|
// Get UNTIL date if specified
|
||||||
let until_date = components.get("UNTIL").and_then(|until_str| {
|
let until_date = components.get("UNTIL").and_then(|until_str| {
|
||||||
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format
|
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format (treat as local time)
|
||||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(
|
||||||
until_str.trim_end_matches('Z'),
|
until_str.trim_end_matches('Z'),
|
||||||
"%Y%m%dT%H%M%S",
|
"%Y%m%dT%H%M%S",
|
||||||
) {
|
) {
|
||||||
Some(chrono::Utc.from_utc_datetime(&dt))
|
Some(dt)
|
||||||
} else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") {
|
} else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") {
|
||||||
Some(dt.with_timezone(&chrono::Utc))
|
// Convert UTC to local (naive) time for consistency
|
||||||
|
Some(dt.naive_utc())
|
||||||
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") {
|
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") {
|
||||||
// Handle date-only UNTIL
|
// Handle date-only UNTIL
|
||||||
Some(chrono::Utc.from_utc_datetime(&date.and_hms_opt(23, 59, 59).unwrap()))
|
Some(date.and_hms_opt(23, 59, 59).unwrap())
|
||||||
} else {
|
} else {
|
||||||
web_sys::console::log_1(
|
web_sys::console::log_1(
|
||||||
&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into(),
|
&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into(),
|
||||||
@@ -391,11 +412,10 @@ impl CalendarService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(until) = until_date {
|
if let Some(_until) = until_date {
|
||||||
web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
let mut current_date = start_date;
|
let mut current_date = start_date;
|
||||||
let mut occurrence_count = 0;
|
let mut occurrence_count = 0;
|
||||||
|
|
||||||
@@ -406,10 +426,6 @@ impl CalendarService {
|
|||||||
let current_datetime = base_event.dtstart
|
let current_datetime = base_event.dtstart
|
||||||
+ Duration::days(current_date.signed_duration_since(start_date).num_days());
|
+ Duration::days(current_date.signed_duration_since(start_date).num_days());
|
||||||
if current_datetime > until {
|
if current_datetime > until {
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!("🛑 Stopping at {} due to UNTIL {}", current_datetime, until)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,8 +438,8 @@ impl CalendarService {
|
|||||||
// Check if this occurrence is in the exception dates (EXDATE)
|
// Check if this occurrence is in the exception dates (EXDATE)
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
// Compare dates ignoring sub-second precision
|
// Compare dates ignoring sub-second precision
|
||||||
let exception_naive = exception_date.naive_utc();
|
let exception_naive = exception_date.and_utc();
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
let occurrence_naive = occurrence_datetime.and_utc();
|
||||||
|
|
||||||
// Check if dates match (within a minute to handle minor time differences)
|
// Check if dates match (within a minute to handle minor time differences)
|
||||||
let diff = occurrence_naive - exception_naive;
|
let diff = occurrence_naive - exception_naive;
|
||||||
@@ -555,7 +571,7 @@ impl CalendarService {
|
|||||||
interval: i32,
|
interval: i32,
|
||||||
start_range: NaiveDate,
|
start_range: NaiveDate,
|
||||||
end_range: NaiveDate,
|
end_range: NaiveDate,
|
||||||
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
until_date: Option<chrono::NaiveDateTime>,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Vec<VEvent> {
|
) -> Vec<VEvent> {
|
||||||
let mut occurrences = Vec::new();
|
let mut occurrences = Vec::new();
|
||||||
@@ -565,7 +581,7 @@ impl CalendarService {
|
|||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
|
|
||||||
// Find the Monday of the week containing the start_date (reference week)
|
// Find the Monday of the week containing the start_date (reference week)
|
||||||
let reference_week_start =
|
let reference_week_start =
|
||||||
@@ -606,13 +622,6 @@ impl CalendarService {
|
|||||||
let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
|
let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
|
||||||
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
||||||
if occurrence_datetime > until {
|
if occurrence_datetime > until {
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"🛑 Stopping at {} due to UNTIL {}",
|
|
||||||
occurrence_datetime, until
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,8 +632,8 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Check if this occurrence is in the exception dates (EXDATE)
|
// Check if this occurrence is in the exception dates (EXDATE)
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
let exception_naive = exception_date.naive_utc();
|
let exception_naive = exception_date.and_utc();
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
let occurrence_naive = occurrence_datetime.and_utc();
|
||||||
let diff = occurrence_naive - exception_naive;
|
let diff = occurrence_naive - exception_naive;
|
||||||
let matches = diff.num_seconds().abs() < 60;
|
let matches = diff.num_seconds().abs() < 60;
|
||||||
|
|
||||||
@@ -675,7 +684,7 @@ impl CalendarService {
|
|||||||
interval: i32,
|
interval: i32,
|
||||||
start_range: NaiveDate,
|
start_range: NaiveDate,
|
||||||
end_range: NaiveDate,
|
end_range: NaiveDate,
|
||||||
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
until_date: Option<chrono::NaiveDateTime>,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Vec<VEvent> {
|
) -> Vec<VEvent> {
|
||||||
let mut occurrences = Vec::new();
|
let mut occurrences = Vec::new();
|
||||||
@@ -691,7 +700,7 @@ impl CalendarService {
|
|||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
let mut current_month_start =
|
let mut current_month_start =
|
||||||
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
||||||
let mut total_occurrences = 0;
|
let mut total_occurrences = 0;
|
||||||
@@ -732,13 +741,6 @@ impl CalendarService {
|
|||||||
occurrence_date.signed_duration_since(start_date).num_days();
|
occurrence_date.signed_duration_since(start_date).num_days();
|
||||||
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
||||||
if occurrence_datetime > until {
|
if occurrence_datetime > until {
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"🛑 Stopping at {} due to UNTIL {}",
|
|
||||||
occurrence_datetime, until
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -749,9 +751,7 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Check if this occurrence is in the exception dates (EXDATE)
|
// Check if this occurrence is in the exception dates (EXDATE)
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
let exception_naive = exception_date.naive_utc();
|
let diff = occurrence_datetime - *exception_date;
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
||||||
let diff = occurrence_naive - exception_naive;
|
|
||||||
diff.num_seconds().abs() < 60
|
diff.num_seconds().abs() < 60
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -792,14 +792,14 @@ impl CalendarService {
|
|||||||
interval: i32,
|
interval: i32,
|
||||||
start_range: NaiveDate,
|
start_range: NaiveDate,
|
||||||
end_range: NaiveDate,
|
end_range: NaiveDate,
|
||||||
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
until_date: Option<chrono::NaiveDateTime>,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Vec<VEvent> {
|
) -> Vec<VEvent> {
|
||||||
let mut occurrences = Vec::new();
|
let mut occurrences = Vec::new();
|
||||||
|
|
||||||
// Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday)
|
// Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday)
|
||||||
if let Some((position, weekday)) = Self::parse_monthly_byday(byday) {
|
if let Some((position, weekday)) = Self::parse_monthly_byday(byday) {
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
let mut current_month_start =
|
let mut current_month_start =
|
||||||
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
||||||
let mut total_occurrences = 0;
|
let mut total_occurrences = 0;
|
||||||
@@ -830,9 +830,7 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Check EXDATE
|
// Check EXDATE
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
let exception_naive = exception_date.naive_utc();
|
let diff = occurrence_datetime - *exception_date;
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
||||||
let diff = occurrence_naive - exception_naive;
|
|
||||||
diff.num_seconds().abs() < 60
|
diff.num_seconds().abs() < 60
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -871,7 +869,7 @@ impl CalendarService {
|
|||||||
interval: i32,
|
interval: i32,
|
||||||
start_range: NaiveDate,
|
start_range: NaiveDate,
|
||||||
end_range: NaiveDate,
|
end_range: NaiveDate,
|
||||||
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
until_date: Option<chrono::NaiveDateTime>,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Vec<VEvent> {
|
) -> Vec<VEvent> {
|
||||||
let mut occurrences = Vec::new();
|
let mut occurrences = Vec::new();
|
||||||
@@ -887,7 +885,7 @@ impl CalendarService {
|
|||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
let mut current_year = start_date.year();
|
let mut current_year = start_date.year();
|
||||||
let mut total_occurrences = 0;
|
let mut total_occurrences = 0;
|
||||||
|
|
||||||
@@ -930,9 +928,7 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Check EXDATE
|
// Check EXDATE
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
let exception_naive = exception_date.naive_utc();
|
let diff = occurrence_datetime - *exception_date;
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
||||||
let diff = occurrence_naive - exception_naive;
|
|
||||||
diff.num_seconds().abs() < 60
|
diff.num_seconds().abs() < 60
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1257,9 +1253,11 @@ impl CalendarService {
|
|||||||
reminder: String,
|
reminder: String,
|
||||||
recurrence: String,
|
recurrence: String,
|
||||||
recurrence_days: Vec<bool>,
|
recurrence_days: Vec<bool>,
|
||||||
|
recurrence_interval: u32,
|
||||||
recurrence_count: Option<u32>,
|
recurrence_count: Option<u32>,
|
||||||
recurrence_until: Option<String>,
|
recurrence_until: Option<String>,
|
||||||
calendar_path: Option<String>,
|
calendar_path: Option<String>,
|
||||||
|
timezone: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
@@ -1290,10 +1288,11 @@ impl CalendarService {
|
|||||||
"reminder": reminder,
|
"reminder": reminder,
|
||||||
"recurrence": recurrence,
|
"recurrence": recurrence,
|
||||||
"recurrence_days": recurrence_days,
|
"recurrence_days": recurrence_days,
|
||||||
"recurrence_interval": 1_u32, // Default interval
|
"recurrence_interval": recurrence_interval,
|
||||||
"recurrence_end_date": recurrence_until,
|
"recurrence_end_date": recurrence_until,
|
||||||
"recurrence_count": recurrence_count,
|
"recurrence_count": recurrence_count,
|
||||||
"calendar_path": calendar_path
|
"calendar_path": calendar_path,
|
||||||
|
"timezone": timezone
|
||||||
});
|
});
|
||||||
let url = format!("{}/calendar/events/series/create", self.base_url);
|
let url = format!("{}/calendar/events/series/create", self.base_url);
|
||||||
(body, url)
|
(body, url)
|
||||||
@@ -1317,7 +1316,8 @@ impl CalendarService {
|
|||||||
"reminder": reminder,
|
"reminder": reminder,
|
||||||
"recurrence": recurrence,
|
"recurrence": recurrence,
|
||||||
"recurrence_days": recurrence_days,
|
"recurrence_days": recurrence_days,
|
||||||
"calendar_path": calendar_path
|
"calendar_path": calendar_path,
|
||||||
|
"timezone": timezone
|
||||||
});
|
});
|
||||||
let url = format!("{}/calendar/events/create", self.base_url);
|
let url = format!("{}/calendar/events/create", self.base_url);
|
||||||
(body, url)
|
(body, url)
|
||||||
@@ -1394,10 +1394,11 @@ impl CalendarService {
|
|||||||
reminder: String,
|
reminder: String,
|
||||||
recurrence: String,
|
recurrence: String,
|
||||||
recurrence_days: Vec<bool>,
|
recurrence_days: Vec<bool>,
|
||||||
|
recurrence_interval: u32,
|
||||||
|
recurrence_count: Option<u32>,
|
||||||
|
recurrence_until: Option<String>,
|
||||||
calendar_path: Option<String>,
|
calendar_path: Option<String>,
|
||||||
exception_dates: Vec<DateTime<Utc>>,
|
timezone: String,
|
||||||
update_action: Option<String>,
|
|
||||||
until_date: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Forward to update_event_with_scope with default scope
|
// Forward to update_event_with_scope with default scope
|
||||||
self.update_event_with_scope(
|
self.update_event_with_scope(
|
||||||
@@ -1421,10 +1422,11 @@ impl CalendarService {
|
|||||||
reminder,
|
reminder,
|
||||||
recurrence,
|
recurrence,
|
||||||
recurrence_days,
|
recurrence_days,
|
||||||
|
recurrence_interval,
|
||||||
|
recurrence_count,
|
||||||
|
recurrence_until,
|
||||||
calendar_path,
|
calendar_path,
|
||||||
exception_dates,
|
timezone,
|
||||||
update_action,
|
|
||||||
until_date,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -1451,10 +1453,11 @@ impl CalendarService {
|
|||||||
reminder: String,
|
reminder: String,
|
||||||
recurrence: String,
|
recurrence: String,
|
||||||
recurrence_days: Vec<bool>,
|
recurrence_days: Vec<bool>,
|
||||||
|
recurrence_interval: u32,
|
||||||
|
recurrence_count: Option<u32>,
|
||||||
|
recurrence_until: Option<String>,
|
||||||
calendar_path: Option<String>,
|
calendar_path: Option<String>,
|
||||||
exception_dates: Vec<DateTime<Utc>>,
|
timezone: String,
|
||||||
update_action: Option<String>,
|
|
||||||
until_date: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
@@ -1482,11 +1485,11 @@ impl CalendarService {
|
|||||||
"reminder": reminder,
|
"reminder": reminder,
|
||||||
"recurrence": recurrence,
|
"recurrence": recurrence,
|
||||||
"recurrence_days": recurrence_days,
|
"recurrence_days": recurrence_days,
|
||||||
|
"recurrence_interval": recurrence_interval,
|
||||||
|
"recurrence_count": recurrence_count,
|
||||||
|
"recurrence_end_date": recurrence_until,
|
||||||
"calendar_path": calendar_path,
|
"calendar_path": calendar_path,
|
||||||
"update_action": update_action,
|
"timezone": timezone
|
||||||
"occurrence_date": null,
|
|
||||||
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
|
|
||||||
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
|
|
||||||
});
|
});
|
||||||
let url = format!("{}/calendar/events/update", self.base_url);
|
let url = format!("{}/calendar/events/update", self.base_url);
|
||||||
|
|
||||||
@@ -1687,11 +1690,13 @@ impl CalendarService {
|
|||||||
reminder: String,
|
reminder: String,
|
||||||
recurrence: String,
|
recurrence: String,
|
||||||
recurrence_days: Vec<bool>,
|
recurrence_days: Vec<bool>,
|
||||||
|
recurrence_interval: u32,
|
||||||
recurrence_count: Option<u32>,
|
recurrence_count: Option<u32>,
|
||||||
recurrence_until: Option<String>,
|
recurrence_until: Option<String>,
|
||||||
calendar_path: Option<String>,
|
calendar_path: Option<String>,
|
||||||
update_scope: String,
|
update_scope: String,
|
||||||
occurrence_date: Option<String>,
|
occurrence_date: Option<String>,
|
||||||
|
timezone: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
@@ -1718,12 +1723,13 @@ impl CalendarService {
|
|||||||
"reminder": reminder,
|
"reminder": reminder,
|
||||||
"recurrence": recurrence,
|
"recurrence": recurrence,
|
||||||
"recurrence_days": recurrence_days,
|
"recurrence_days": recurrence_days,
|
||||||
"recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter
|
"recurrence_interval": recurrence_interval,
|
||||||
"recurrence_end_date": recurrence_until,
|
"recurrence_end_date": recurrence_until,
|
||||||
"recurrence_count": recurrence_count,
|
"recurrence_count": recurrence_count,
|
||||||
"calendar_path": calendar_path,
|
"calendar_path": calendar_path,
|
||||||
"update_scope": update_scope,
|
"update_scope": update_scope,
|
||||||
"occurrence_date": occurrence_date
|
"occurrence_date": occurrence_date,
|
||||||
|
"timezone": timezone
|
||||||
});
|
});
|
||||||
|
|
||||||
let url = format!("{}/calendar/events/series/update", self.base_url);
|
let url = format!("{}/calendar/events/series/update", self.base_url);
|
||||||
@@ -2095,7 +2101,6 @@ impl CalendarService {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ExternalCalendarEventsResponse {
|
struct ExternalCalendarEventsResponse {
|
||||||
events: Vec<VEvent>,
|
events: Vec<VEvent>,
|
||||||
last_fetched: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
|
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ body {
|
|||||||
border-radius: var(--border-radius-medium);
|
border-radius: var(--border-radius-medium);
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form h2, .register-form h2 {
|
.login-form h2, .register-form h2 {
|
||||||
@@ -492,30 +492,83 @@ body {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-checkbox {
|
.input-with-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 1rem;
|
||||||
margin-top: 0.375rem;
|
}
|
||||||
|
|
||||||
|
.input-with-checkbox input[type="text"],
|
||||||
|
.input-with-checkbox input[type="password"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-checkbox {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-checkbox input[type="checkbox"] {
|
.remember-checkbox input[type="checkbox"] {
|
||||||
width: auto;
|
width: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transform: scale(0.85);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-checkbox label {
|
.remember-checkbox label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.55rem;
|
||||||
color: #888;
|
color: #888;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.password-input-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-container input {
|
||||||
|
padding-right: 3rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn:hover {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn:focus {
|
||||||
|
outline: none;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
.login-button, .register-button {
|
.login-button, .register-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--control-padding);
|
padding: var(--control-padding);
|
||||||
@@ -1826,7 +1879,7 @@ body {
|
|||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.4rem;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user