Compare commits
35 Commits
feature/ex
...
ac1164fd81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac1164fd81 | ||
|
|
a6092d13ce | ||
|
|
acc5ced551 | ||
|
|
890940fe31 | ||
|
|
fdea5cd646 | ||
|
|
b307be7eb1 | ||
|
|
9d84c380d1 | ||
|
|
fad03f94f9 | ||
| a4476dcfae | |||
|
|
ca1ca0c3b1 | ||
|
|
64dbf65beb | ||
|
|
96585440d1 | ||
|
|
a297d38276 | ||
|
|
4fdaa9931d | ||
|
|
c6c7b38bef | ||
|
|
78db2cc00f | ||
|
|
73d191c5ca | ||
| d930468748 | |||
|
|
91be4436a9 | ||
|
|
4cbc495c48 | ||
|
|
927cd7d2bb | ||
|
|
38b22287c7 | ||
|
|
0de2eee626 | ||
|
|
aa7a15e6fa | ||
|
|
b0a8ef09a8 | ||
|
|
efbaea5ac1 | ||
|
|
bbad327ea2 | ||
|
|
72273a3f1c | ||
|
|
8329244c69 | ||
|
|
b16603b50b | ||
|
|
c6eea88002 | ||
|
|
5876553515 | ||
|
|
d73bc78af5 | ||
|
|
393bfecff2 | ||
| aab478202b |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -330,13 +330,26 @@ impl CalDAVClient {
|
|||||||
event: ical::parser::ical::component::IcalEvent,
|
event: ical::parser::ical::component::IcalEvent,
|
||||||
) -> Result<CalendarEvent, CalDAVError> {
|
) -> Result<CalendarEvent, CalDAVError> {
|
||||||
let mut properties: HashMap<String, String> = HashMap::new();
|
let mut properties: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut full_properties: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
// Extract all properties from the event
|
// Extract all properties from the event
|
||||||
for property in &event.properties {
|
for property in &event.properties {
|
||||||
properties.insert(
|
let prop_name = property.name.to_uppercase();
|
||||||
property.name.to_uppercase(),
|
let prop_value = property.value.clone().unwrap_or_default();
|
||||||
property.value.clone().unwrap_or_default(),
|
|
||||||
);
|
properties.insert(prop_name.clone(), prop_value.clone());
|
||||||
|
|
||||||
|
// Build full property string with parameters for timezone parsing
|
||||||
|
let mut full_prop = format!("{}", prop_name);
|
||||||
|
if let Some(params) = &property.params {
|
||||||
|
for (param_name, param_values) in params {
|
||||||
|
if !param_values.is_empty() {
|
||||||
|
full_prop.push_str(&format!(";{}={}", param_name, param_values.join(",")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
full_prop.push_str(&format!(":{}", prop_value));
|
||||||
|
full_properties.insert(prop_name, full_prop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required UID field
|
// Required UID field
|
||||||
@@ -346,19 +359,20 @@ impl CalDAVClient {
|
|||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
// 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, 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, 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
|
// Determine if it's an all-day event by checking for VALUE=DATE parameter
|
||||||
@@ -398,23 +412,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)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
let last_modified = properties
|
let (last_modified_naive, last_modified_tzid) = if let Some(modified_str) = properties.get("LAST-MODIFIED") {
|
||||||
.get("LAST-MODIFIED")
|
match self.parse_datetime_with_tz(modified_str, None) {
|
||||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
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();
|
||||||
@@ -437,10 +463,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
|
||||||
@@ -567,12 +596,32 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
let mut all_calendars = Vec::new();
|
let mut all_calendars = Vec::new();
|
||||||
|
|
||||||
|
let mut has_valid_caldav_response = false;
|
||||||
|
|
||||||
for path in discovery_paths {
|
for path in discovery_paths {
|
||||||
println!("Trying discovery path: {}", path);
|
println!("Trying discovery path: {}", path);
|
||||||
if let Ok(calendars) = self.discover_calendars_at_path(&path).await {
|
match self.discover_calendars_at_path(&path).await {
|
||||||
|
Ok(calendars) => {
|
||||||
println!("Found {} calendar(s) at {}", calendars.len(), path);
|
println!("Found {} calendar(s) at {}", calendars.len(), path);
|
||||||
|
has_valid_caldav_response = true;
|
||||||
all_calendars.extend(calendars);
|
all_calendars.extend(calendars);
|
||||||
}
|
}
|
||||||
|
Err(CalDAVError::ServerError(status)) => {
|
||||||
|
// HTTP error - this might be expected for some paths, continue trying
|
||||||
|
println!("Discovery path {} returned HTTP {}, trying next path", path, status);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we never got a valid CalDAV response (e.g., all requests failed),
|
||||||
|
// this is likely not a CalDAV server
|
||||||
|
if !has_valid_caldav_response {
|
||||||
|
return Err(CalDAVError::ServerError(404));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove duplicates
|
// Remove duplicates
|
||||||
@@ -671,17 +720,38 @@ impl CalDAVClient {
|
|||||||
Ok(calendar_paths)
|
Ok(calendar_paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse iCal datetime format
|
/// Parse iCal datetime format and return NaiveDateTime + timezone info
|
||||||
fn parse_datetime(
|
/// According to RFC 5545: if no TZID parameter is provided, treat as UTC
|
||||||
|
fn parse_datetime_with_tz(
|
||||||
&self,
|
&self,
|
||||||
datetime_str: &str,
|
datetime_str: &str,
|
||||||
_original_property: Option<&String>,
|
original_property: Option<&String>,
|
||||||
) -> Result<DateTime<Utc>, CalDAVError> {
|
) -> Result<(chrono::NaiveDateTime, Option<String>), CalDAVError> {
|
||||||
use chrono::TimeZone;
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle different iCal datetime formats
|
// Clean the datetime string - remove any TZID prefix if present
|
||||||
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
|
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
|
// Try different parsing formats
|
||||||
let formats = [
|
let formats = [
|
||||||
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
||||||
@@ -690,17 +760,230 @@ impl CalDAVClient {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for format in &formats {
|
for format in &formats {
|
||||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) {
|
// Try parsing as UTC format (with Z suffix)
|
||||||
return Ok(Utc.from_utc_datetime(&dt));
|
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())));
|
||||||
}
|
}
|
||||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) {
|
}
|
||||||
|
|
||||||
|
// 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())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as naive datetime
|
||||||
|
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
|
||||||
|
fn parse_datetime(
|
||||||
|
&self,
|
||||||
|
datetime_str: &str,
|
||||||
|
original_property: Option<&String>,
|
||||||
|
) -> Result<DateTime<Utc>, CalDAVError> {
|
||||||
|
use chrono::TimeZone;
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
|
||||||
|
// Extract timezone information from the original property if available
|
||||||
|
let mut timezone_id: Option<&str> = 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]);
|
||||||
|
} else if let Some(tzid_end) = tzid_part.find(';') {
|
||||||
|
timezone_id = Some(&tzid_part[..tzid_end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 first (if it has 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") {
|
||||||
|
return Ok(dt.and_utc());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing with timezone offset (e.g., 20231225T120000-0500)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") {
|
||||||
|
return Ok(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") {
|
||||||
|
return Ok(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") {
|
||||||
|
return Ok(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as naive datetime
|
||||||
|
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) {
|
||||||
|
// If we have timezone information, convert accordingly
|
||||||
|
if let Some(tz_id) = timezone_id {
|
||||||
|
let tz_result = if tz_id.starts_with("/mozilla.org/") {
|
||||||
|
// Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London
|
||||||
|
tz_id.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok())
|
||||||
|
} else if tz_id.contains('/') {
|
||||||
|
// Standard timezone format: America/New_York, Europe/London
|
||||||
|
tz_id.parse::<Tz>().ok()
|
||||||
|
} else {
|
||||||
|
// Try common abbreviations and Windows timezone names
|
||||||
|
match tz_id {
|
||||||
|
// Standard abbreviations
|
||||||
|
"EST" => Some(Tz::America__New_York),
|
||||||
|
"PST" => Some(Tz::America__Los_Angeles),
|
||||||
|
"MST" => Some(Tz::America__Denver),
|
||||||
|
"CST" => Some(Tz::America__Chicago),
|
||||||
|
|
||||||
|
// North America - Windows timezone names to IANA mapping
|
||||||
|
"Mountain Standard Time" => Some(Tz::America__Denver),
|
||||||
|
"Eastern Standard Time" => Some(Tz::America__New_York),
|
||||||
|
"Central Standard Time" => Some(Tz::America__Chicago),
|
||||||
|
"Pacific Standard Time" => Some(Tz::America__Los_Angeles),
|
||||||
|
"Mountain Daylight Time" => Some(Tz::America__Denver),
|
||||||
|
"Eastern Daylight Time" => Some(Tz::America__New_York),
|
||||||
|
"Central Daylight Time" => Some(Tz::America__Chicago),
|
||||||
|
"Pacific Daylight Time" => Some(Tz::America__Los_Angeles),
|
||||||
|
"Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu),
|
||||||
|
"Alaskan Standard Time" => Some(Tz::America__Anchorage),
|
||||||
|
"Alaskan Daylight Time" => Some(Tz::America__Anchorage),
|
||||||
|
"Atlantic Standard Time" => Some(Tz::America__Halifax),
|
||||||
|
"Newfoundland Standard Time" => Some(Tz::America__St_Johns),
|
||||||
|
|
||||||
|
// Europe
|
||||||
|
"GMT Standard Time" => Some(Tz::Europe__London),
|
||||||
|
"Greenwich Standard Time" => Some(Tz::UTC),
|
||||||
|
"W. Europe Standard Time" => Some(Tz::Europe__Berlin),
|
||||||
|
"Central Europe Standard Time" => Some(Tz::Europe__Warsaw),
|
||||||
|
"Romance Standard Time" => Some(Tz::Europe__Paris),
|
||||||
|
"Central European Standard Time" => Some(Tz::Europe__Belgrade),
|
||||||
|
"E. Europe Standard Time" => Some(Tz::Europe__Bucharest),
|
||||||
|
"FLE Standard Time" => Some(Tz::Europe__Helsinki),
|
||||||
|
"GTB Standard Time" => Some(Tz::Europe__Athens),
|
||||||
|
"Russian Standard Time" => Some(Tz::Europe__Moscow),
|
||||||
|
"Turkey Standard Time" => Some(Tz::Europe__Istanbul),
|
||||||
|
|
||||||
|
// Asia
|
||||||
|
"China Standard Time" => Some(Tz::Asia__Shanghai),
|
||||||
|
"Tokyo Standard Time" => Some(Tz::Asia__Tokyo),
|
||||||
|
"Korea Standard Time" => Some(Tz::Asia__Seoul),
|
||||||
|
"Singapore Standard Time" => Some(Tz::Asia__Singapore),
|
||||||
|
"India Standard Time" => Some(Tz::Asia__Kolkata),
|
||||||
|
"Pakistan Standard Time" => Some(Tz::Asia__Karachi),
|
||||||
|
"Bangladesh Standard Time" => Some(Tz::Asia__Dhaka),
|
||||||
|
"Thailand Standard Time" => Some(Tz::Asia__Bangkok),
|
||||||
|
"SE Asia Standard Time" => Some(Tz::Asia__Bangkok),
|
||||||
|
"Myanmar Standard Time" => Some(Tz::Asia__Yangon),
|
||||||
|
"Sri Lanka Standard Time" => Some(Tz::Asia__Colombo),
|
||||||
|
"Nepal Standard Time" => Some(Tz::Asia__Kathmandu),
|
||||||
|
"Central Asia Standard Time" => Some(Tz::Asia__Almaty),
|
||||||
|
"West Asia Standard Time" => Some(Tz::Asia__Tashkent),
|
||||||
|
"N. Central Asia Standard Time" => Some(Tz::Asia__Novosibirsk),
|
||||||
|
"North Asia Standard Time" => Some(Tz::Asia__Krasnoyarsk),
|
||||||
|
"North Asia East Standard Time" => Some(Tz::Asia__Irkutsk),
|
||||||
|
"Yakutsk Standard Time" => Some(Tz::Asia__Yakutsk),
|
||||||
|
"Vladivostok Standard Time" => Some(Tz::Asia__Vladivostok),
|
||||||
|
"Magadan Standard Time" => Some(Tz::Asia__Magadan),
|
||||||
|
|
||||||
|
// Australia & Pacific
|
||||||
|
"AUS Eastern Standard Time" => Some(Tz::Australia__Sydney),
|
||||||
|
"AUS Central Standard Time" => Some(Tz::Australia__Adelaide),
|
||||||
|
"W. Australia Standard Time" => Some(Tz::Australia__Perth),
|
||||||
|
"Tasmania Standard Time" => Some(Tz::Australia__Hobart),
|
||||||
|
"New Zealand Standard Time" => Some(Tz::Pacific__Auckland),
|
||||||
|
"Fiji Standard Time" => Some(Tz::Pacific__Fiji),
|
||||||
|
"Tonga Standard Time" => Some(Tz::Pacific__Tongatapu),
|
||||||
|
|
||||||
|
// Africa & Middle East
|
||||||
|
"South Africa Standard Time" => Some(Tz::Africa__Johannesburg),
|
||||||
|
"Egypt Standard Time" => Some(Tz::Africa__Cairo),
|
||||||
|
"Israel Standard Time" => Some(Tz::Asia__Jerusalem),
|
||||||
|
"Iran Standard Time" => Some(Tz::Asia__Tehran),
|
||||||
|
"Arabic Standard Time" => Some(Tz::Asia__Baghdad),
|
||||||
|
"Arab Standard Time" => Some(Tz::Asia__Riyadh),
|
||||||
|
|
||||||
|
// South America
|
||||||
|
"SA Eastern Standard Time" => Some(Tz::America__Sao_Paulo),
|
||||||
|
"Argentina Standard Time" => Some(Tz::America__Buenos_Aires),
|
||||||
|
"SA Western Standard Time" => Some(Tz::America__La_Paz),
|
||||||
|
"SA Pacific Standard Time" => Some(Tz::America__Bogota),
|
||||||
|
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tz) = tz_result {
|
||||||
|
// Convert from the specified timezone to UTC
|
||||||
|
if let Some(local_dt) = tz.from_local_datetime(&naive_dt).single() {
|
||||||
|
return Ok(local_dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If timezone parsing fails, fall back to UTC
|
||||||
|
}
|
||||||
|
// No timezone info or parsing failed - treat as UTC
|
||||||
|
return Ok(Utc.from_utc_datetime(&naive_dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as date only
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) {
|
||||||
return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()));
|
return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(CalDAVError::ParseError(format!(
|
Err(CalDAVError::ParseError(format!(
|
||||||
"Unable to parse datetime: {}",
|
"Unable to parse datetime: {} (cleaned: {}, timezone: {:?})",
|
||||||
datetime_str
|
datetime_str, datetime_part, timezone_id
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,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();
|
||||||
@@ -1041,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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1105,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)));
|
||||||
@@ -1162,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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,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() {
|
||||||
@@ -234,26 +234,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 +453,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 +594,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 +761,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 +792,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 +830,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,37 +941,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::{Local, 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))
|
||||||
|
|
||||||
// Treat the datetime as local time and convert to UTC
|
|
||||||
let local_datetime = Local.from_local_datetime(&datetime)
|
|
||||||
.single()
|
|
||||||
.ok_or_else(|| "Ambiguous local datetime".to_string())?;
|
|
||||||
|
|
||||||
Ok(local_datetime.with_timezone(&Utc))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc, Datelike};
|
||||||
use ical::parser::ical::component::IcalEvent;
|
use ical::parser::ical::component::IcalEvent;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -78,16 +78,74 @@ pub async fn fetch_external_calendar_events(
|
|||||||
|
|
||||||
// If not fetched from cache, get from external URL
|
// If not fetched from cache, get from external URL
|
||||||
if !fetched_from_cache {
|
if !fetched_from_cache {
|
||||||
let client = Client::new();
|
// Log the URL being fetched for debugging
|
||||||
let response = client
|
println!("🌍 Fetching calendar URL: {}", calendar.url);
|
||||||
.get(&calendar.url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let user_agents = vec![
|
||||||
return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status())));
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (compatible; Runway Calendar/1.0)",
|
||||||
|
"Outlook-iOS/709.2226530.prod.iphone (3.24.1)"
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut response = None;
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
// Try different user agents
|
||||||
|
for (i, ua) in user_agents.iter().enumerate() {
|
||||||
|
println!("🔄 Attempt {} with User-Agent: {}", i + 1, ua);
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(10))
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.user_agent(*ua)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to create HTTP client: {}", e)))?;
|
||||||
|
|
||||||
|
let result = client
|
||||||
|
.get(&calendar.url)
|
||||||
|
.header("Accept", "text/calendar,application/calendar+xml,text/plain,*/*")
|
||||||
|
.header("Accept-Charset", "utf-8")
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
println!("📡 Response status: {}", status);
|
||||||
|
if status.is_success() {
|
||||||
|
response = Some(resp);
|
||||||
|
break;
|
||||||
|
} else if status == 400 {
|
||||||
|
// Check if this is an Outlook auth error
|
||||||
|
let error_body = resp.text().await.unwrap_or_default();
|
||||||
|
if error_body.contains("OwaPage") || error_body.contains("Outlook") {
|
||||||
|
println!("🚫 Outlook authentication error detected, trying next approach...");
|
||||||
|
last_error = Some(format!("Outlook auth error: {}", error_body.chars().take(100).collect::<String>()));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
last_error = Some(format!("Bad Request: {}", error_body.chars().take(100).collect::<String>()));
|
||||||
|
} else {
|
||||||
|
last_error = Some(format!("HTTP {}", status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("❌ Request failed: {}", e);
|
||||||
|
last_error = Some(format!("Request error: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = response.ok_or_else(|| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to fetch calendar after trying {} different approaches. Last error: {}",
|
||||||
|
user_agents.len(),
|
||||||
|
last_error.unwrap_or("Unknown error".to_string())
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Response is guaranteed to be successful here since we checked in the loop
|
||||||
|
println!("✅ Successfully fetched calendar data");
|
||||||
|
|
||||||
ics_content = response
|
ics_content = response
|
||||||
.text()
|
.text()
|
||||||
@@ -138,6 +196,9 @@ fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate events based on UID, start time, and summary
|
||||||
|
// Outlook sometimes includes duplicate events (recurring exceptions may appear multiple times)
|
||||||
|
events = deduplicate_events(events);
|
||||||
|
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
@@ -224,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,
|
||||||
@@ -252,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,
|
||||||
@@ -408,3 +476,438 @@ fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<Date
|
|||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deduplicate events based on UID, start time, and summary
|
||||||
|
/// Some calendar systems (like Outlook) may include duplicate events in ICS feeds
|
||||||
|
/// This includes both exact duplicates and recurring event instances that would be
|
||||||
|
/// generated by existing RRULE patterns, and events with same title but different
|
||||||
|
/// RRULE patterns that should be consolidated
|
||||||
|
fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let original_count = events.len();
|
||||||
|
|
||||||
|
// First pass: Group by UID and prefer recurring events over single events with same UID
|
||||||
|
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
for event in events.drain(..) {
|
||||||
|
// Debug logging to understand what's happening
|
||||||
|
println!("🔍 Event: '{}' at {} (RRULE: {}) - UID: {}",
|
||||||
|
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
event.dtstart.format("%Y-%m-%d %H:%M"),
|
||||||
|
if event.rrule.is_some() { "Yes" } else { "No" },
|
||||||
|
event.uid
|
||||||
|
);
|
||||||
|
|
||||||
|
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut uid_deduplicated_events = Vec::new();
|
||||||
|
|
||||||
|
for (uid, mut events_with_uid) in uid_groups.drain() {
|
||||||
|
if events_with_uid.len() == 1 {
|
||||||
|
// Only one event with this UID, keep it
|
||||||
|
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
|
||||||
|
} else {
|
||||||
|
// Multiple events with same UID - prefer recurring over non-recurring
|
||||||
|
println!("🔍 Found {} events with UID '{}'", events_with_uid.len(), uid);
|
||||||
|
|
||||||
|
// Sort by preference: recurring events first, then by completeness
|
||||||
|
events_with_uid.sort_by(|a, b| {
|
||||||
|
let a_has_rrule = a.rrule.is_some();
|
||||||
|
let b_has_rrule = b.rrule.is_some();
|
||||||
|
|
||||||
|
match (a_has_rrule, b_has_rrule) {
|
||||||
|
(true, false) => std::cmp::Ordering::Less, // a (recurring) comes first
|
||||||
|
(false, true) => std::cmp::Ordering::Greater, // b (recurring) comes first
|
||||||
|
_ => {
|
||||||
|
// Both same type (both recurring or both single) - compare by completeness
|
||||||
|
event_completeness_score(b).cmp(&event_completeness_score(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the first (preferred) event
|
||||||
|
let preferred_event = events_with_uid.into_iter().next().unwrap();
|
||||||
|
println!("🔄 UID dedup: Keeping '{}' (RRULE: {})",
|
||||||
|
preferred_event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
if preferred_event.rrule.is_some() { "Yes" } else { "No" }
|
||||||
|
);
|
||||||
|
uid_deduplicated_events.push(preferred_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: separate recurring and single events from UID-deduplicated set
|
||||||
|
let mut recurring_events = Vec::new();
|
||||||
|
let mut single_events = Vec::new();
|
||||||
|
|
||||||
|
for event in uid_deduplicated_events.drain(..) {
|
||||||
|
if event.rrule.is_some() {
|
||||||
|
recurring_events.push(event);
|
||||||
|
} else {
|
||||||
|
single_events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third pass: Group recurring events by normalized title and consolidate different RRULE patterns
|
||||||
|
let mut title_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
for event in recurring_events.drain(..) {
|
||||||
|
let title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
title_groups.entry(title).or_insert_with(Vec::new).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deduplicated_recurring = Vec::new();
|
||||||
|
|
||||||
|
for (title, events_with_title) in title_groups.drain() {
|
||||||
|
if events_with_title.len() == 1 {
|
||||||
|
// Single event with this title, keep as-is
|
||||||
|
deduplicated_recurring.push(events_with_title.into_iter().next().unwrap());
|
||||||
|
} else {
|
||||||
|
// Multiple events with same title - consolidate or deduplicate
|
||||||
|
println!("🔍 Found {} events with title '{}'", events_with_title.len(), title);
|
||||||
|
|
||||||
|
// Check if these are actually different recurring patterns for the same logical event
|
||||||
|
let consolidated = consolidate_same_title_events(events_with_title);
|
||||||
|
deduplicated_recurring.extend(consolidated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fourth pass: filter single events, removing those that would be generated by recurring events
|
||||||
|
let mut deduplicated_single = Vec::new();
|
||||||
|
let mut seen_single: HashMap<String, usize> = HashMap::new();
|
||||||
|
|
||||||
|
for event in single_events.drain(..) {
|
||||||
|
let normalized_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
let dedup_key = format!(
|
||||||
|
"{}|{}",
|
||||||
|
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||||
|
normalized_title
|
||||||
|
);
|
||||||
|
|
||||||
|
// First check for exact duplicates among single events
|
||||||
|
if let Some(&existing_index) = seen_single.get(&dedup_key) {
|
||||||
|
let existing_event: &VEvent = &deduplicated_single[existing_index];
|
||||||
|
let current_completeness = event_completeness_score(&event);
|
||||||
|
let existing_completeness = event_completeness_score(existing_event);
|
||||||
|
|
||||||
|
if current_completeness > existing_completeness {
|
||||||
|
println!("🔄 Replacing single event: Keeping '{}' over '{}'",
|
||||||
|
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||||
|
);
|
||||||
|
deduplicated_single[existing_index] = event;
|
||||||
|
} else {
|
||||||
|
println!("🚫 Discarding duplicate single event: Keeping existing '{}'",
|
||||||
|
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this single event would be generated by any recurring event
|
||||||
|
let is_rrule_generated = deduplicated_recurring.iter().any(|recurring_event| {
|
||||||
|
// Check if this single event matches the recurring event's pattern (use normalized titles)
|
||||||
|
let single_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
let recurring_title = normalize_title(recurring_event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
|
||||||
|
if single_title != recurring_title {
|
||||||
|
return false; // Different events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this single event would be generated by the recurring event
|
||||||
|
would_event_be_generated_by_rrule(recurring_event, &event)
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_rrule_generated {
|
||||||
|
println!("🚫 Discarding RRULE-generated instance: '{}' at {} would be generated by recurring event",
|
||||||
|
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
event.dtstart.format("%Y-%m-%d %H:%M")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// This is a unique single event
|
||||||
|
seen_single.insert(dedup_key, deduplicated_single.len());
|
||||||
|
deduplicated_single.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine recurring and single events
|
||||||
|
let mut result = deduplicated_recurring;
|
||||||
|
result.extend(deduplicated_single);
|
||||||
|
|
||||||
|
println!("📊 Deduplication complete: {} -> {} events ({} recurring, {} single)",
|
||||||
|
original_count, result.len(),
|
||||||
|
result.iter().filter(|e| e.rrule.is_some()).count(),
|
||||||
|
result.iter().filter(|e| e.rrule.is_none()).count()
|
||||||
|
);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize title for grouping similar events
|
||||||
|
fn normalize_title(title: &str) -> String {
|
||||||
|
title.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consolidate events with the same title but potentially different RRULE patterns
|
||||||
|
/// This handles cases where calendar systems provide multiple recurring definitions
|
||||||
|
/// for the same logical meeting (e.g., one RRULE for Tuesdays, another for Thursdays)
|
||||||
|
fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
if events.is_empty() {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the RRULEs we're working with
|
||||||
|
for event in &events {
|
||||||
|
if let Some(rrule) = &event.rrule {
|
||||||
|
println!("🔍 RRULE for '{}': {}",
|
||||||
|
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||||
|
rrule
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all events have similar time patterns and could be consolidated
|
||||||
|
let first_event = &events[0];
|
||||||
|
let base_time = first_event.dtstart.time();
|
||||||
|
let base_duration = if let Some(end) = first_event.dtend {
|
||||||
|
Some(end.signed_duration_since(first_event.dtstart))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if all events have the same time and duration
|
||||||
|
let can_consolidate = events.iter().all(|event| {
|
||||||
|
let same_time = event.dtstart.time() == base_time;
|
||||||
|
let same_duration = match (event.dtend, base_duration) {
|
||||||
|
(Some(end), Some(base_dur)) => end.signed_duration_since(event.dtstart) == base_dur,
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
same_time && same_duration
|
||||||
|
});
|
||||||
|
|
||||||
|
if !can_consolidate {
|
||||||
|
println!("🚫 Cannot consolidate events - different times or durations");
|
||||||
|
// Just deduplicate exact duplicates
|
||||||
|
return deduplicate_exact_recurring_events(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect if these are complementary weekly patterns
|
||||||
|
let weekly_events: Vec<_> = events.iter()
|
||||||
|
.filter(|e| e.rrule.as_ref().map_or(false, |r| r.contains("FREQ=WEEKLY")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if weekly_events.len() >= 2 && weekly_events.len() == events.len() {
|
||||||
|
// All events are weekly - try to consolidate into a single multi-day weekly pattern
|
||||||
|
if let Some(consolidated) = consolidate_weekly_patterns(&events) {
|
||||||
|
println!("✅ Successfully consolidated {} weekly patterns into one", events.len());
|
||||||
|
return vec![consolidated];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't consolidate, just deduplicate exact matches and keep the most complete one
|
||||||
|
println!("🚫 Cannot consolidate - keeping most complete event");
|
||||||
|
let deduplicated = deduplicate_exact_recurring_events(events);
|
||||||
|
|
||||||
|
// If we still have multiple events, keep only the most complete one
|
||||||
|
if deduplicated.len() > 1 {
|
||||||
|
let best_event = deduplicated.into_iter()
|
||||||
|
.max_by_key(|e| event_completeness_score(e))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("🎯 Kept most complete event: '{}'",
|
||||||
|
best_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||||
|
);
|
||||||
|
vec![best_event]
|
||||||
|
} else {
|
||||||
|
deduplicated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicate exact recurring event matches
|
||||||
|
fn deduplicate_exact_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let mut seen: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut deduplicated = Vec::new();
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let dedup_key = format!(
|
||||||
|
"{}|{}|{}",
|
||||||
|
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||||
|
event.summary.as_ref().unwrap_or(&String::new()),
|
||||||
|
event.rrule.as_ref().unwrap_or(&String::new())
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(&existing_index) = seen.get(&dedup_key) {
|
||||||
|
let existing_event: &VEvent = &deduplicated[existing_index];
|
||||||
|
let current_completeness = event_completeness_score(&event);
|
||||||
|
let existing_completeness = event_completeness_score(existing_event);
|
||||||
|
|
||||||
|
if current_completeness > existing_completeness {
|
||||||
|
println!("🔄 Replacing exact duplicate: Keeping more complete event");
|
||||||
|
deduplicated[existing_index] = event;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seen.insert(dedup_key, deduplicated.len());
|
||||||
|
deduplicated.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deduplicated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to consolidate multiple weekly RRULE patterns into a single pattern
|
||||||
|
fn consolidate_weekly_patterns(events: &[VEvent]) -> Option<VEvent> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let mut all_days = HashSet::new();
|
||||||
|
let mut base_event = None;
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let Some(rrule) = &event.rrule else { continue; };
|
||||||
|
|
||||||
|
if !rrule.contains("FREQ=WEEKLY") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract BYDAY if present
|
||||||
|
if let Some(byday_part) = rrule.split(';').find(|part| part.starts_with("BYDAY=")) {
|
||||||
|
let days_str = byday_part.strip_prefix("BYDAY=").unwrap_or("");
|
||||||
|
for day in days_str.split(',') {
|
||||||
|
all_days.insert(day.trim().to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no BYDAY specified, use the weekday from the start date
|
||||||
|
let weekday = match event.dtstart.weekday() {
|
||||||
|
chrono::Weekday::Mon => "MO",
|
||||||
|
chrono::Weekday::Tue => "TU",
|
||||||
|
chrono::Weekday::Wed => "WE",
|
||||||
|
chrono::Weekday::Thu => "TH",
|
||||||
|
chrono::Weekday::Fri => "FR",
|
||||||
|
chrono::Weekday::Sat => "SA",
|
||||||
|
chrono::Weekday::Sun => "SU",
|
||||||
|
};
|
||||||
|
all_days.insert(weekday.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first event as the base (we already know they have same time/duration)
|
||||||
|
if base_event.is_none() {
|
||||||
|
base_event = Some(event.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_days.is_empty() || base_event.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create consolidated RRULE
|
||||||
|
let mut base = base_event.unwrap();
|
||||||
|
let days_list: Vec<_> = all_days.into_iter().collect();
|
||||||
|
let byday_str = days_list.join(",");
|
||||||
|
|
||||||
|
// Build new RRULE with consolidated BYDAY
|
||||||
|
let new_rrule = if let Some(existing_rrule) = &base.rrule {
|
||||||
|
// Remove existing BYDAY and add our consolidated one
|
||||||
|
let parts: Vec<_> = existing_rrule.split(';')
|
||||||
|
.filter(|part| !part.starts_with("BYDAY="))
|
||||||
|
.collect();
|
||||||
|
format!("{};BYDAY={}", parts.join(";"), byday_str)
|
||||||
|
} else {
|
||||||
|
format!("FREQ=WEEKLY;BYDAY={}", byday_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
base.rrule = Some(new_rrule);
|
||||||
|
|
||||||
|
println!("🔗 Consolidated weekly pattern: BYDAY={}", byday_str);
|
||||||
|
Some(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a single event would be generated by a recurring event's RRULE
|
||||||
|
fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VEvent) -> bool {
|
||||||
|
let Some(rrule) = &recurring_event.rrule else {
|
||||||
|
return false; // No RRULE to check against
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse basic RRULE patterns
|
||||||
|
if rrule.contains("FREQ=DAILY") {
|
||||||
|
// Daily recurrence
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
|
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||||
|
|
||||||
|
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
||||||
|
// Check if times match (allowing for timezone differences within same day)
|
||||||
|
let recurring_time = recurring_event.dtstart.time();
|
||||||
|
let single_time = single_event.dtstart.time();
|
||||||
|
return recurring_time == single_time;
|
||||||
|
}
|
||||||
|
} else if rrule.contains("FREQ=WEEKLY") {
|
||||||
|
// Weekly recurrence
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
|
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||||
|
|
||||||
|
// First check if it's the same day of week and time
|
||||||
|
let recurring_weekday = recurring_event.dtstart.weekday();
|
||||||
|
let single_weekday = single_event.dtstart.weekday();
|
||||||
|
let recurring_time = recurring_event.dtstart.time();
|
||||||
|
let single_time = single_event.dtstart.time();
|
||||||
|
|
||||||
|
if recurring_weekday == single_weekday && recurring_time == single_time && days_diff >= 0 {
|
||||||
|
// Calculate how many weeks apart they are
|
||||||
|
let weeks_diff = days_diff / 7;
|
||||||
|
// Check if this falls on an interval boundary
|
||||||
|
return weeks_diff % interval as i64 == 0;
|
||||||
|
}
|
||||||
|
} else if rrule.contains("FREQ=MONTHLY") {
|
||||||
|
// Monthly recurrence - simplified check
|
||||||
|
let months_diff = (single_event.dtstart.year() - recurring_event.dtstart.year()) * 12
|
||||||
|
+ (single_event.dtstart.month() as i32 - recurring_event.dtstart.month() as i32);
|
||||||
|
|
||||||
|
if months_diff >= 0 {
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1) as i32;
|
||||||
|
if months_diff % interval == 0 {
|
||||||
|
// Same day of month and time
|
||||||
|
return recurring_event.dtstart.day() == single_event.dtstart.day()
|
||||||
|
&& recurring_event.dtstart.time() == single_event.dtstart.time();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract INTERVAL value from RRULE string, defaulting to 1 if not found
|
||||||
|
fn extract_interval_from_rrule(rrule: &str) -> Option<u32> {
|
||||||
|
for part in rrule.split(';') {
|
||||||
|
if part.starts_with("INTERVAL=") {
|
||||||
|
return part.strip_prefix("INTERVAL=")
|
||||||
|
.and_then(|s| s.parse().ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(1) // Default interval is 1 if not specified
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate a completeness score for an event based on how many optional fields are filled
|
||||||
|
fn event_completeness_score(event: &VEvent) -> u32 {
|
||||||
|
let mut score = 0;
|
||||||
|
|
||||||
|
if event.summary.is_some() { score += 1; }
|
||||||
|
if event.description.is_some() { score += 1; }
|
||||||
|
if event.location.is_some() { score += 1; }
|
||||||
|
if event.dtend.is_some() { score += 1; }
|
||||||
|
if event.rrule.is_some() { score += 1; }
|
||||||
|
if !event.categories.is_empty() { score += 1; }
|
||||||
|
if !event.alarms.is_empty() { score += 1; }
|
||||||
|
if event.organizer.is_some() { score += 1; }
|
||||||
|
if !event.attendees.is_empty() { score += 1; }
|
||||||
|
|
||||||
|
score
|
||||||
|
}
|
||||||
@@ -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,92 +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()))?;
|
|
||||||
|
|
||||||
// Convert from local time to UTC
|
|
||||||
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
|
||||||
.single()
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
|
||||||
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
|
||||||
.single()
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
|
||||||
|
|
||||||
(
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert from local time to UTC
|
|
||||||
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
|
||||||
.single()
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
|
||||||
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
|
||||||
.single()
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
|
||||||
|
|
||||||
(
|
|
||||||
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 {
|
||||||
@@ -265,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)?;
|
||||||
@@ -380,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
|
||||||
@@ -407,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 {
|
||||||
@@ -418,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(|_| {
|
||||||
@@ -453,21 +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
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert from local time to UTC
|
// Frontend now sends local times, so use them directly
|
||||||
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
(start_dt, end_dt)
|
||||||
.single()
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
|
||||||
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
|
||||||
.single()
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
|
||||||
|
|
||||||
(
|
|
||||||
start_local.with_timezone(&chrono::Utc),
|
|
||||||
end_local.with_timezone(&chrono::Utc),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle different update scopes
|
// Handle different update scopes
|
||||||
@@ -714,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();
|
||||||
@@ -723,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 {
|
||||||
@@ -755,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
|
||||||
@@ -844,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> {
|
||||||
@@ -893,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 {
|
||||||
@@ -925,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!(
|
||||||
@@ -955,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,
|
||||||
@@ -981,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
|
||||||
@@ -1007,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 {
|
||||||
@@ -1039,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
|
||||||
@@ -1056,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)
|
||||||
@@ -1184,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
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
export BACKEND_API_URL="https://runway.rcjohnstone.com/api"
|
||||||
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
|
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
|
||||||
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/
|
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/
|
||||||
|
unset BACKEND_API_URL
|
||||||
|
|||||||
@@ -22,14 +22,19 @@ web-sys = { version = "0.3", features = [
|
|||||||
"Document",
|
"Document",
|
||||||
"Window",
|
"Window",
|
||||||
"Location",
|
"Location",
|
||||||
|
"Navigator",
|
||||||
|
"DomTokenList",
|
||||||
"Headers",
|
"Headers",
|
||||||
"Request",
|
"Request",
|
||||||
"RequestInit",
|
"RequestInit",
|
||||||
"RequestMode",
|
"RequestMode",
|
||||||
"Response",
|
"Response",
|
||||||
"CssStyleDeclaration",
|
"CssStyleDeclaration",
|
||||||
|
"MediaQueryList",
|
||||||
|
"MediaQueryListEvent",
|
||||||
] }
|
] }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
js-sys = "0.3"
|
||||||
|
|
||||||
# HTTP client for CalDAV requests
|
# HTTP client for CalDAV requests
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ dist = "dist"
|
|||||||
BACKEND_API_URL = "http://localhost:3000/api"
|
BACKEND_API_URL = "http://localhost:3000/api"
|
||||||
|
|
||||||
[watch]
|
[watch]
|
||||||
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "index.html"]
|
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "print-preview.css", "index.html"]
|
||||||
ignore = ["../backend/", "../target/"]
|
ignore = ["../backend/", "../target/"]
|
||||||
|
|
||||||
[serve]
|
[serve]
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<base data-trunk-public-url />
|
<base data-trunk-public-url />
|
||||||
<link data-trunk rel="css" href="styles.css">
|
<link data-trunk rel="css" href="styles.css">
|
||||||
|
<link data-trunk rel="css" href="print-preview.css">
|
||||||
<link data-trunk rel="copy-file" href="styles/google.css">
|
<link data-trunk rel="copy-file" href="styles/google.css">
|
||||||
<link data-trunk rel="icon" href="favicon.ico">
|
<link data-trunk rel="icon" href="favicon.ico">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
1215
frontend/print-preview.css
Normal file
1215
frontend/print-preview.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
|||||||
use crate::components::{
|
use crate::components::{
|
||||||
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
|
CalendarContextMenu, CalendarManagementModal, ContextMenu, CreateEventModal, DeleteAction,
|
||||||
EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler,
|
EditAction, EventContextMenu, EventModal, EventCreationData,
|
||||||
Sidebar, Theme, ViewMode,
|
MobileWarningModal, RouteHandler, Sidebar, Theme, ViewMode,
|
||||||
};
|
};
|
||||||
|
use crate::components::mobile_warning_modal::is_mobile_device;
|
||||||
use crate::components::sidebar::{Style};
|
use crate::components::sidebar::{Style};
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
||||||
@@ -55,11 +56,46 @@ fn get_theme_event_colors() -> Vec<String> {
|
|||||||
|
|
||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn App() -> Html {
|
pub fn App() -> Html {
|
||||||
let auth_token = use_state(|| -> Option<String> { LocalStorage::get("auth_token").ok() });
|
let auth_token = use_state(|| -> Option<String> { None });
|
||||||
|
|
||||||
|
// Validate token on app startup
|
||||||
|
{
|
||||||
|
let auth_token = auth_token.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
let auth_token = auth_token.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
// Check if there's a stored token
|
||||||
|
if let Ok(stored_token) = LocalStorage::get::<String>("auth_token") {
|
||||||
|
// Verify the stored token
|
||||||
|
let auth_service = crate::auth::AuthService::new();
|
||||||
|
match auth_service.verify_token(&stored_token).await {
|
||||||
|
Ok(true) => {
|
||||||
|
// Token is valid, set it
|
||||||
|
web_sys::console::log_1(&"✅ Stored auth token is valid".into());
|
||||||
|
auth_token.set(Some(stored_token));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Token is invalid or verification failed, clear it
|
||||||
|
web_sys::console::log_1(&"❌ Stored auth token is invalid, clearing".into());
|
||||||
|
let _ = LocalStorage::delete("auth_token");
|
||||||
|
let _ = LocalStorage::delete("session_token");
|
||||||
|
let _ = LocalStorage::delete("caldav_credentials");
|
||||||
|
auth_token.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No stored token
|
||||||
|
web_sys::console::log_1(&"ℹ️ No stored auth token found".into());
|
||||||
|
auth_token.set(None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let user_info = use_state(|| -> Option<UserInfo> { None });
|
let user_info = use_state(|| -> Option<UserInfo> { None });
|
||||||
let color_picker_open = use_state(|| -> Option<String> { None });
|
let color_picker_open = use_state(|| -> Option<String> { None });
|
||||||
let create_modal_open = use_state(|| false);
|
let calendar_management_modal_open = use_state(|| false);
|
||||||
let context_menu_open = use_state(|| false);
|
let context_menu_open = use_state(|| false);
|
||||||
let context_menu_pos = use_state(|| (0i32, 0i32));
|
let context_menu_pos = use_state(|| (0i32, 0i32));
|
||||||
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
|
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
|
||||||
@@ -72,6 +108,9 @@ pub fn App() -> Html {
|
|||||||
let create_event_modal_open = use_state(|| false);
|
let create_event_modal_open = use_state(|| false);
|
||||||
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
|
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
|
||||||
let event_edit_scope = use_state(|| -> Option<EditAction> { None });
|
let event_edit_scope = use_state(|| -> Option<EditAction> { None });
|
||||||
|
let view_event_modal_open = use_state(|| false);
|
||||||
|
let view_event_modal_event = use_state(|| -> Option<VEvent> { None });
|
||||||
|
let refreshing_calendar_id = use_state(|| -> Option<i32> { None });
|
||||||
let _recurring_edit_modal_open = use_state(|| false);
|
let _recurring_edit_modal_open = use_state(|| false);
|
||||||
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
|
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
|
||||||
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
|
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
|
||||||
@@ -79,7 +118,9 @@ pub fn App() -> Html {
|
|||||||
// External calendar state
|
// External calendar state
|
||||||
let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() });
|
let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() });
|
||||||
let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() });
|
let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() });
|
||||||
let external_calendar_modal_open = use_state(|| false);
|
|
||||||
|
// Mobile warning state
|
||||||
|
let mobile_warning_open = use_state(|| is_mobile_device());
|
||||||
let refresh_interval = use_state(|| -> Option<Interval> { None });
|
let refresh_interval = use_state(|| -> Option<Interval> { None });
|
||||||
|
|
||||||
// Calendar view state - load from localStorage if available
|
// Calendar view state - load from localStorage if available
|
||||||
@@ -117,6 +158,108 @@ pub fn App() -> Html {
|
|||||||
|
|
||||||
let available_colors = use_state(|| get_theme_event_colors());
|
let available_colors = use_state(|| get_theme_event_colors());
|
||||||
|
|
||||||
|
// Function to refresh calendar data without full page reload
|
||||||
|
let refresh_calendar_data = {
|
||||||
|
let user_info = user_info.clone();
|
||||||
|
let auth_token = auth_token.clone();
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
|
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let user_info = user_info.clone();
|
||||||
|
let auth_token = auth_token.clone();
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
// Refresh main calendar data if authenticated
|
||||||
|
if let Some(token) = (*auth_token).clone() {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
let password = if let Ok(credentials_str) =
|
||||||
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !password.is_empty() {
|
||||||
|
match calendar_service.fetch_user_info(&token, &password).await {
|
||||||
|
Ok(mut info) => {
|
||||||
|
// Apply saved colors
|
||||||
|
if let Ok(saved_colors_json) =
|
||||||
|
LocalStorage::get::<String>("calendar_colors")
|
||||||
|
{
|
||||||
|
if let Ok(saved_info) =
|
||||||
|
serde_json::from_str::<UserInfo>(&saved_colors_json)
|
||||||
|
{
|
||||||
|
for saved_cal in &saved_info.calendars {
|
||||||
|
for cal in &mut info.calendars {
|
||||||
|
if cal.path == saved_cal.path {
|
||||||
|
cal.color = saved_cal.color.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add timestamp to force re-render
|
||||||
|
info.last_updated = (js_sys::Date::now() / 1000.0) as u64;
|
||||||
|
user_info.set(Some(info));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("Failed to refresh main calendar data: {}", err).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh external calendars data
|
||||||
|
match CalendarService::get_external_calendars().await {
|
||||||
|
Ok(calendars) => {
|
||||||
|
external_calendars.set(calendars.clone());
|
||||||
|
|
||||||
|
// Load events for visible external calendars
|
||||||
|
let mut all_external_events = Vec::new();
|
||||||
|
for calendar in calendars {
|
||||||
|
if calendar.is_visible {
|
||||||
|
match CalendarService::fetch_external_calendar_events(calendar.id).await {
|
||||||
|
Ok(mut events) => {
|
||||||
|
// Set calendar_path for color matching
|
||||||
|
for event in &mut events {
|
||||||
|
event.calendar_path = Some(format!("external_{}", calendar.id));
|
||||||
|
}
|
||||||
|
all_external_events.extend(events);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("Failed to fetch events for external calendar {}: {}", calendar.id, e).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
external_calendar_events.set(all_external_events);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("Failed to refresh external calendars: {}", e).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_login = {
|
let on_login = {
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
Callback::from(move |token: String| {
|
Callback::from(move |token: String| {
|
||||||
@@ -134,6 +277,13 @@ pub fn App() -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_mobile_warning_close = {
|
||||||
|
let mobile_warning_open = mobile_warning_open.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
mobile_warning_open.set(false);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_view_change = {
|
let on_view_change = {
|
||||||
let current_view = current_view.clone();
|
let current_view = current_view.clone();
|
||||||
Callback::from(move |new_view: ViewMode| {
|
Callback::from(move |new_view: ViewMode| {
|
||||||
@@ -417,8 +567,48 @@ pub fn App() -> Html {
|
|||||||
|
|
||||||
let on_color_change = {
|
let on_color_change = {
|
||||||
let user_info = user_info.clone();
|
let user_info = user_info.clone();
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
let color_picker_open = color_picker_open.clone();
|
let color_picker_open = color_picker_open.clone();
|
||||||
Callback::from(move |(calendar_path, color): (String, String)| {
|
Callback::from(move |(calendar_path, color): (String, String)| {
|
||||||
|
if calendar_path.starts_with("external_") {
|
||||||
|
// Handle external calendar color change
|
||||||
|
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
|
let color = color.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
// Find the external calendar to get its current details
|
||||||
|
if let Some(cal) = (*external_calendars).iter().find(|c| c.id == id_str) {
|
||||||
|
match CalendarService::update_external_calendar(
|
||||||
|
id_str,
|
||||||
|
&cal.name,
|
||||||
|
&cal.url,
|
||||||
|
&color,
|
||||||
|
cal.is_visible,
|
||||||
|
).await {
|
||||||
|
Ok(_) => {
|
||||||
|
// Update the local state
|
||||||
|
let mut updated_calendars = (*external_calendars).clone();
|
||||||
|
for calendar in &mut updated_calendars {
|
||||||
|
if calendar.id == id_str {
|
||||||
|
calendar.color = color.clone();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
external_calendars.set(updated_calendars);
|
||||||
|
|
||||||
|
// No need to refresh events - they will automatically pick up the new color
|
||||||
|
// from the calendar when rendered since they use the same calendar_path matching
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
web_sys::console::error_1(&format!("Failed to update external calendar color: {}", e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle CalDAV calendar color change (existing logic)
|
||||||
if let Some(mut info) = (*user_info).clone() {
|
if let Some(mut info) = (*user_info).clone() {
|
||||||
for calendar in &mut info.calendars {
|
for calendar in &mut info.calendars {
|
||||||
if calendar.path == calendar_path {
|
if calendar.path == calendar_path {
|
||||||
@@ -432,6 +622,7 @@ pub fn App() -> Html {
|
|||||||
let _ = LocalStorage::set("calendar_colors", json);
|
let _ = LocalStorage::set("calendar_colors", json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
color_picker_open.set(None);
|
color_picker_open.set(None);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -493,6 +684,7 @@ pub fn App() -> Html {
|
|||||||
let on_event_create = {
|
let on_event_create = {
|
||||||
let create_event_modal_open = create_event_modal_open.clone();
|
let create_event_modal_open = create_event_modal_open.clone();
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
|
let refresh_calendar_data = refresh_calendar_data.clone();
|
||||||
Callback::from(move |event_data: EventCreationData| {
|
Callback::from(move |event_data: EventCreationData| {
|
||||||
// Check if this is an update operation (has original_uid) or a create operation
|
// Check if this is an update operation (has original_uid) or a create operation
|
||||||
if let Some(original_uid) = event_data.original_uid.clone() {
|
if let Some(original_uid) = event_data.original_uid.clone() {
|
||||||
@@ -503,6 +695,7 @@ pub fn App() -> Html {
|
|||||||
// Handle the update operation using the existing backend update logic
|
// Handle the update operation using the existing backend update logic
|
||||||
if let Some(token) = (*auth_token).clone() {
|
if let Some(token) = (*auth_token).clone() {
|
||||||
let event_data_for_update = event_data.clone();
|
let event_data_for_update = event_data.clone();
|
||||||
|
let refresh_callback = refresh_calendar_data.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();
|
||||||
|
|
||||||
@@ -530,8 +723,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(),
|
||||||
@@ -561,11 +758,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 {
|
||||||
@@ -592,10 +791,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
|
||||||
};
|
};
|
||||||
@@ -603,10 +803,8 @@ pub fn App() -> Html {
|
|||||||
match update_result {
|
match update_result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
web_sys::console::log_1(&"Event updated successfully via modal".into());
|
web_sys::console::log_1(&"Event updated successfully via modal".into());
|
||||||
// Trigger a page reload to refresh events from all calendars
|
// Refresh calendar data without page reload
|
||||||
if let Some(window) = web_sys::window() {
|
refresh_callback.emit(());
|
||||||
let _ = window.location().reload();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::error_1(
|
web_sys::console::error_1(
|
||||||
@@ -642,6 +840,7 @@ pub fn App() -> Html {
|
|||||||
create_event_modal_open.set(false);
|
create_event_modal_open.set(false);
|
||||||
|
|
||||||
if let Some(_token) = (*auth_token).clone() {
|
if let Some(_token) = (*auth_token).clone() {
|
||||||
|
let refresh_callback = refresh_calendar_data.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();
|
||||||
|
|
||||||
@@ -680,17 +879,18 @@ 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 {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
web_sys::console::log_1(&"Event created successfully".into());
|
web_sys::console::log_1(&"Event created successfully".into());
|
||||||
// Trigger a page reload to refresh events from all calendars
|
// Refresh calendar data without page reload
|
||||||
// TODO: This could be improved to do a more targeted refresh
|
refresh_callback.emit(());
|
||||||
web_sys::window().unwrap().location().reload().unwrap();
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::error_1(
|
web_sys::console::error_1(
|
||||||
@@ -709,6 +909,7 @@ pub fn App() -> Html {
|
|||||||
|
|
||||||
let on_event_update = {
|
let on_event_update = {
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
|
let refresh_calendar_data = refresh_calendar_data.clone();
|
||||||
Callback::from(
|
Callback::from(
|
||||||
move |(
|
move |(
|
||||||
original_event,
|
original_event,
|
||||||
@@ -723,7 +924,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>,
|
||||||
)| {
|
)| {
|
||||||
@@ -743,6 +944,7 @@ pub fn App() -> Html {
|
|||||||
if let Some(token) = (*auth_token).clone() {
|
if let Some(token) = (*auth_token).clone() {
|
||||||
let original_event = original_event.clone();
|
let original_event = original_event.clone();
|
||||||
let backend_uid = backend_uid.clone();
|
let backend_uid = backend_uid.clone();
|
||||||
|
let refresh_callback = refresh_calendar_data.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();
|
||||||
|
|
||||||
@@ -761,7 +963,9 @@ pub fn App() -> Html {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send local time directly to backend (backend will handle UTC conversion)
|
|
||||||
|
|
||||||
|
// Send local times to backend, which will handle timezone conversion
|
||||||
let start_date = new_start.format("%Y-%m-%d").to_string();
|
let start_date = new_start.format("%Y-%m-%d").to_string();
|
||||||
let start_time = new_start.format("%H:%M").to_string();
|
let start_time = new_start.format("%H:%M").to_string();
|
||||||
let end_date = new_end.format("%Y-%m-%d").to_string();
|
let end_date = new_end.format("%Y-%m-%d").to_string();
|
||||||
@@ -844,12 +1048,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,
|
||||||
)
|
)
|
||||||
@@ -893,14 +1106,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
|
||||||
};
|
};
|
||||||
@@ -908,14 +1125,8 @@ pub fn App() -> Html {
|
|||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
web_sys::console::log_1(&"Event updated successfully".into());
|
web_sys::console::log_1(&"Event updated successfully".into());
|
||||||
// Add small delay before reload to let any pending requests complete
|
// Refresh calendar data without page reload
|
||||||
wasm_bindgen_futures::spawn_local(async {
|
refresh_callback.emit(());
|
||||||
gloo_timers::future::sleep(std::time::Duration::from_millis(
|
|
||||||
100,
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
web_sys::window().unwrap().location().reload().unwrap();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::error_1(
|
web_sys::console::error_1(
|
||||||
@@ -1002,13 +1213,9 @@ pub fn App() -> Html {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
user_info={(*user_info).clone()}
|
user_info={(*user_info).clone()}
|
||||||
on_logout={on_logout}
|
on_logout={on_logout}
|
||||||
on_create_calendar={Callback::from({
|
on_add_calendar={Callback::from({
|
||||||
let create_modal_open = create_modal_open.clone();
|
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||||
move |_| create_modal_open.set(true)
|
move |_| calendar_management_modal_open.set(true)
|
||||||
})}
|
|
||||||
on_create_external_calendar={Callback::from({
|
|
||||||
let external_calendar_modal_open = external_calendar_modal_open.clone();
|
|
||||||
move |_| external_calendar_modal_open.set(true)
|
|
||||||
})}
|
})}
|
||||||
external_calendars={(*external_calendars).clone()}
|
external_calendars={(*external_calendars).clone()}
|
||||||
on_external_calendar_toggle={Callback::from({
|
on_external_calendar_toggle={Callback::from({
|
||||||
@@ -1093,12 +1300,23 @@ pub fn App() -> Html {
|
|||||||
on_external_calendar_refresh={Callback::from({
|
on_external_calendar_refresh={Callback::from({
|
||||||
let external_calendar_events = external_calendar_events.clone();
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
let external_calendars = external_calendars.clone();
|
let external_calendars = external_calendars.clone();
|
||||||
|
let refreshing_calendar_id = refreshing_calendar_id.clone();
|
||||||
move |id: i32| {
|
move |id: i32| {
|
||||||
let external_calendar_events = external_calendar_events.clone();
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
let external_calendars = external_calendars.clone();
|
let external_calendars = external_calendars.clone();
|
||||||
|
let refreshing_calendar_id = refreshing_calendar_id.clone();
|
||||||
|
|
||||||
|
// Set loading state
|
||||||
|
refreshing_calendar_id.set(Some(id));
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
web_sys::console::log_1(&format!("🔄 Refreshing external calendar {}", id).into());
|
||||||
|
|
||||||
// Force refresh of this specific calendar
|
// Force refresh of this specific calendar
|
||||||
if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await {
|
match CalendarService::fetch_external_calendar_events(id).await {
|
||||||
|
Ok(mut events) => {
|
||||||
|
web_sys::console::log_1(&format!("✅ Successfully refreshed calendar {} with {} events", id, events.len()).into());
|
||||||
|
|
||||||
// Set calendar_path for color matching
|
// Set calendar_path for color matching
|
||||||
for event in &mut events {
|
for event in &mut events {
|
||||||
event.calendar_path = Some(format!("external_{}", id));
|
event.calendar_path = Some(format!("external_{}", id));
|
||||||
@@ -1119,8 +1337,28 @@ pub fn App() -> Html {
|
|||||||
external_calendar_events.set(all_events);
|
external_calendar_events.set(all_events);
|
||||||
|
|
||||||
// Update the last_fetched timestamp in calendars list
|
// Update the last_fetched timestamp in calendars list
|
||||||
if let Ok(calendars) = CalendarService::get_external_calendars().await {
|
match CalendarService::get_external_calendars().await {
|
||||||
|
Ok(calendars) => {
|
||||||
external_calendars.set(calendars);
|
external_calendars.set(calendars);
|
||||||
|
web_sys::console::log_1(&"✅ Calendar list updated with new timestamps".into());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::error_1(&format!("⚠️ Failed to update calendar list: {}", err).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear loading state on success
|
||||||
|
refreshing_calendar_id.set(None);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::error_1(&format!("❌ Failed to refresh calendar {}: {}", id, err).into());
|
||||||
|
// Show error to user
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.alert_with_message(&format!("Failed to refresh calendar: {}", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear loading state on error
|
||||||
|
refreshing_calendar_id.set(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1130,6 +1368,7 @@ pub fn App() -> Html {
|
|||||||
on_color_change={on_color_change}
|
on_color_change={on_color_change}
|
||||||
on_color_picker_toggle={on_color_picker_toggle}
|
on_color_picker_toggle={on_color_picker_toggle}
|
||||||
available_colors={(*available_colors).clone()}
|
available_colors={(*available_colors).clone()}
|
||||||
|
refreshing_calendar_id={(*refreshing_calendar_id).clone()}
|
||||||
on_calendar_context_menu={on_calendar_context_menu}
|
on_calendar_context_menu={on_calendar_context_menu}
|
||||||
on_calendar_visibility_toggle={Callback::from({
|
on_calendar_visibility_toggle={Callback::from({
|
||||||
let user_info = user_info.clone();
|
let user_info = user_info.clone();
|
||||||
@@ -1188,20 +1427,20 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<CreateCalendarModal
|
<CalendarManagementModal
|
||||||
is_open={*create_modal_open}
|
is_open={*calendar_management_modal_open}
|
||||||
on_close={Callback::from({
|
on_close={Callback::from({
|
||||||
let create_modal_open = create_modal_open.clone();
|
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||||
move |_| create_modal_open.set(false)
|
move |_| calendar_management_modal_open.set(false)
|
||||||
})}
|
})}
|
||||||
on_create={Callback::from({
|
on_create_calendar={Callback::from({
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
let refresh_calendars = refresh_calendars.clone();
|
let refresh_calendars = refresh_calendars.clone();
|
||||||
let create_modal_open = create_modal_open.clone();
|
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||||
move |(name, description, color): (String, Option<String>, Option<String>)| {
|
move |(name, description, color): (String, Option<String>, Option<String>)| {
|
||||||
if let Some(token) = (*auth_token).clone() {
|
if let Some(token) = (*auth_token).clone() {
|
||||||
let refresh_calendars = refresh_calendars.clone();
|
let refresh_calendars = refresh_calendars.clone();
|
||||||
let create_modal_open = create_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();
|
||||||
@@ -1220,17 +1459,41 @@ pub fn App() -> Html {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
web_sys::console::log_1(&"Calendar created successfully!".into());
|
web_sys::console::log_1(&"Calendar created successfully!".into());
|
||||||
refresh_calendars.emit(());
|
refresh_calendars.emit(());
|
||||||
create_modal_open.set(false);
|
calendar_management_modal_open.set(false);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into());
|
web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into());
|
||||||
create_modal_open.set(false);
|
calendar_management_modal_open.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
on_external_success={Callback::from({
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
|
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||||
|
move |new_id: i32| {
|
||||||
|
// Refresh external calendars list
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
|
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
match CalendarService::get_external_calendars().await {
|
||||||
|
Ok(calendars) => {
|
||||||
|
external_calendars.set(calendars);
|
||||||
|
calendar_management_modal_open.set(false);
|
||||||
|
web_sys::console::log_1(&format!("External calendar {} added successfully!", new_id).into());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::error_1(&format!("Failed to refresh external calendars: {}", err).into());
|
||||||
|
calendar_management_modal_open.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})}
|
||||||
available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
|
available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1303,10 +1566,10 @@ pub fn App() -> Html {
|
|||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
let event_context_menu_event = event_context_menu_event.clone();
|
let event_context_menu_event = event_context_menu_event.clone();
|
||||||
let event_context_menu_open = event_context_menu_open.clone();
|
let event_context_menu_open = event_context_menu_open.clone();
|
||||||
let refresh_calendars = refresh_calendars.clone();
|
let refresh_calendar_data = refresh_calendar_data.clone();
|
||||||
move |delete_action: DeleteAction| {
|
move |delete_action: DeleteAction| {
|
||||||
if let (Some(token), Some(event)) = ((*auth_token).clone(), (*event_context_menu_event).clone()) {
|
if let (Some(token), Some(event)) = ((*auth_token).clone(), (*event_context_menu_event).clone()) {
|
||||||
let _refresh_calendars = refresh_calendars.clone();
|
let refresh_calendar_data = refresh_calendar_data.clone();
|
||||||
let event_context_menu_open = event_context_menu_open.clone();
|
let event_context_menu_open = event_context_menu_open.clone();
|
||||||
|
|
||||||
// Log the delete action for now - we'll implement different behaviors later
|
// Log the delete action for now - we'll implement different behaviors later
|
||||||
@@ -1316,6 +1579,7 @@ pub fn App() -> Html {
|
|||||||
DeleteAction::DeleteSeries => web_sys::console::log_1(&"Delete entire series".into()),
|
DeleteAction::DeleteSeries => web_sys::console::log_1(&"Delete entire series".into()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let refresh_callback = refresh_calendar_data.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();
|
||||||
|
|
||||||
@@ -1338,7 +1602,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());
|
||||||
@@ -1363,8 +1627,8 @@ pub fn App() -> Html {
|
|||||||
|
|
||||||
// Close the context menu
|
// Close the context menu
|
||||||
event_context_menu_open.set(false);
|
event_context_menu_open.set(false);
|
||||||
// Force a page reload to refresh the calendar events
|
// Refresh calendar data without page reload
|
||||||
web_sys::window().unwrap().location().reload().unwrap();
|
refresh_callback.emit(());
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("Failed to delete event: {}", err).into());
|
web_sys::console::log_1(&format!("Failed to delete event: {}", err).into());
|
||||||
@@ -1378,6 +1642,30 @@ 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({
|
||||||
|
let event_context_menu_open = event_context_menu_open.clone();
|
||||||
|
let view_event_modal_open = view_event_modal_open.clone();
|
||||||
|
let view_event_modal_event = view_event_modal_event.clone();
|
||||||
|
move |event: VEvent| {
|
||||||
|
// Set the event for viewing (read-only mode)
|
||||||
|
view_event_modal_event.set(Some(event));
|
||||||
|
event_context_menu_open.set(false);
|
||||||
|
view_event_modal_open.set(true);
|
||||||
|
}
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CalendarContextMenu
|
<CalendarContextMenu
|
||||||
@@ -1413,59 +1701,28 @@ pub fn App() -> Html {
|
|||||||
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
|
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExternalCalendarModal
|
|
||||||
is_open={*external_calendar_modal_open}
|
<EventModal
|
||||||
|
event={if *view_event_modal_open { (*view_event_modal_event).clone() } else { None }}
|
||||||
on_close={Callback::from({
|
on_close={Callback::from({
|
||||||
let external_calendar_modal_open = external_calendar_modal_open.clone();
|
let view_event_modal_open = view_event_modal_open.clone();
|
||||||
move |_| external_calendar_modal_open.set(false)
|
let view_event_modal_event = view_event_modal_event.clone();
|
||||||
})}
|
move |_| {
|
||||||
on_success={Callback::from({
|
view_event_modal_open.set(false);
|
||||||
let external_calendars = external_calendars.clone();
|
view_event_modal_event.set(None);
|
||||||
let external_calendar_events = external_calendar_events.clone();
|
|
||||||
move |new_calendar_id: i32| {
|
|
||||||
let external_calendars = external_calendars.clone();
|
|
||||||
let external_calendar_events = external_calendar_events.clone();
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
// First, refresh the calendar list to get the new calendar
|
|
||||||
match CalendarService::get_external_calendars().await {
|
|
||||||
Ok(calendars) => {
|
|
||||||
external_calendars.set(calendars.clone());
|
|
||||||
|
|
||||||
// Then immediately fetch events for the new calendar if it's visible
|
|
||||||
if let Some(new_calendar) = calendars.iter().find(|c| c.id == new_calendar_id) {
|
|
||||||
if new_calendar.is_visible {
|
|
||||||
match CalendarService::fetch_external_calendar_events(new_calendar_id).await {
|
|
||||||
Ok(mut events) => {
|
|
||||||
// Set calendar_path for color matching
|
|
||||||
for event in &mut events {
|
|
||||||
event.calendar_path = Some(format!("external_{}", new_calendar_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the new calendar's events to existing events
|
|
||||||
let mut all_events = (*external_calendar_events).clone();
|
|
||||||
all_events.extend(events);
|
|
||||||
external_calendar_events.set(all_events);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!("Failed to fetch events for new calendar {}: {}", new_calendar_id, e).into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!("Failed to refresh calendars after creation: {}", err).into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
// Mobile warning modal
|
||||||
|
<MobileWarningModal
|
||||||
|
is_open={*mobile_warning_open}
|
||||||
|
on_close={on_mobile_warning_close}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// Hidden print copy that gets shown only during printing
|
||||||
|
<div id="print-preview-copy" class="print-preview-paper" style="display: none;"></div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,50 @@ impl AuthService {
|
|||||||
self.post_json("/auth/login", &request).await
|
self.post_json("/auth/login", &request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn verify_token(&self, token: &str) -> Result<bool, String> {
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let url = format!("{}/auth/verify", self.base_url);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if resp.ok() {
|
||||||
|
let text = JsFuture::from(
|
||||||
|
resp.text()
|
||||||
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||||
|
|
||||||
|
// Parse the response to get the "valid" field
|
||||||
|
let response: serde_json::Value = serde_json::from_str(&text_string)
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||||
|
|
||||||
|
Ok(response.get("valid").and_then(|v| v.as_bool()).unwrap_or(false))
|
||||||
|
} else {
|
||||||
|
Ok(false) // Invalid token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper method for POST requests with JSON body
|
// Helper method for POST requests with JSON body
|
||||||
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::components::{
|
use crate::components::{
|
||||||
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView,
|
||||||
};
|
};
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
||||||
@@ -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>,
|
||||||
)>,
|
)>,
|
||||||
@@ -389,6 +389,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle print calendar preview
|
||||||
|
let show_print_preview = use_state(|| false);
|
||||||
|
let on_print = {
|
||||||
|
let show_print_preview = show_print_preview.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
show_print_preview.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// Handle drag-to-create event
|
// Handle drag-to-create event
|
||||||
let on_create_event = {
|
let on_create_event = {
|
||||||
let show_create_modal = show_create_modal.clone();
|
let show_create_modal = show_create_modal.clone();
|
||||||
@@ -428,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>,
|
||||||
)| {
|
)| {
|
||||||
@@ -457,6 +466,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
on_today={on_today}
|
on_today={on_today}
|
||||||
time_increment={Some(*time_increment)}
|
time_increment={Some(*time_increment)}
|
||||||
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||||
|
on_print={Some(on_print)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -563,6 +573,32 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
// Print preview modal
|
||||||
|
{
|
||||||
|
if *show_print_preview {
|
||||||
|
html! {
|
||||||
|
<PrintPreviewModal
|
||||||
|
on_close={{
|
||||||
|
let show_print_preview = show_print_preview.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_print_preview.set(false);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
view_mode={props.view.clone()}
|
||||||
|
current_date={*current_date}
|
||||||
|
selected_date={*selected_date}
|
||||||
|
events={(*events).clone()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
time_increment={*time_increment}
|
||||||
|
today={today}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub struct CalendarHeaderProps {
|
|||||||
pub time_increment: Option<u32>,
|
pub time_increment: Option<u32>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
|
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_print: Option<Callback<MouseEvent>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(CalendarHeader)]
|
#[function_component(CalendarHeader)]
|
||||||
@@ -39,6 +41,17 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
if let Some(print_callback) = &props.on_print {
|
||||||
|
html! {
|
||||||
|
<button class="print-button" onclick={print_callback.clone()} title="Print Calendar">
|
||||||
|
<i class="fas fa-print"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<h2 class="month-year">{title}</h2>
|
<h2 class="month-year">{title}</h2>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
{
|
{
|
||||||
if props.color_picker_open {
|
if props.color_picker_open {
|
||||||
html! {
|
html! {
|
||||||
<div class="color-picker">
|
<div class="color-picker-dropdown">
|
||||||
{
|
{
|
||||||
props.available_colors.iter().map(|color| {
|
props.available_colors.iter().map(|color| {
|
||||||
let color_str = color.clone();
|
let color_str = color.clone();
|
||||||
|
|||||||
449
frontend/src/components/calendar_management_modal.rs
Normal file
449
frontend/src/components/calendar_management_modal.rs
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use crate::services::calendar_service::CalendarService;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum CalendarTab {
|
||||||
|
Create,
|
||||||
|
External,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarManagementModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_create_calendar: Callback<(String, Option<String>, Option<String>)>, // name, description, color
|
||||||
|
pub on_external_success: Callback<i32>, // Pass the newly created external calendar ID
|
||||||
|
pub available_colors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CalendarManagementModal)]
|
||||||
|
pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
|
||||||
|
let active_tab = use_state(|| CalendarTab::Create);
|
||||||
|
|
||||||
|
// Create Calendar state
|
||||||
|
let calendar_name = use_state(|| String::new());
|
||||||
|
let description = use_state(|| String::new());
|
||||||
|
let selected_color = use_state(|| None::<String>);
|
||||||
|
let create_error_message = use_state(|| None::<String>);
|
||||||
|
let is_creating = use_state(|| false);
|
||||||
|
|
||||||
|
// External Calendar state
|
||||||
|
let external_name = use_state(|| String::new());
|
||||||
|
let external_url = use_state(|| String::new());
|
||||||
|
let external_selected_color = use_state(|| Some("#4285f4".to_string()));
|
||||||
|
let external_is_loading = use_state(|| false);
|
||||||
|
let external_error_message = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
// Reset state when modal opens
|
||||||
|
use_effect_with(props.is_open, {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
let description = description.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let create_error_message = create_error_message.clone();
|
||||||
|
let is_creating = is_creating.clone();
|
||||||
|
let external_name = external_name.clone();
|
||||||
|
let external_url = external_url.clone();
|
||||||
|
let external_is_loading = external_is_loading.clone();
|
||||||
|
let external_error_message = external_error_message.clone();
|
||||||
|
let external_selected_color = external_selected_color.clone();
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
|
||||||
|
move |is_open| {
|
||||||
|
if *is_open {
|
||||||
|
// Reset all state when modal opens
|
||||||
|
calendar_name.set(String::new());
|
||||||
|
description.set(String::new());
|
||||||
|
selected_color.set(None);
|
||||||
|
create_error_message.set(None);
|
||||||
|
is_creating.set(false);
|
||||||
|
external_name.set(String::new());
|
||||||
|
external_url.set(String::new());
|
||||||
|
external_is_loading.set(false);
|
||||||
|
external_error_message.set(None);
|
||||||
|
external_selected_color.set(Some("#4285f4".to_string()));
|
||||||
|
active_tab.set(CalendarTab::Create);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_tab_click = {
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
Callback::from(move |tab: CalendarTab| {
|
||||||
|
active_tab.set(tab);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
let element = target.dyn_into::<web_sys::Element>().unwrap();
|
||||||
|
if element.class_list().contains("modal-backdrop") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Calendar handlers
|
||||||
|
let on_name_change = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
calendar_name.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_description_change = {
|
||||||
|
let description = description.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
|
||||||
|
description.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_color_select = {
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
Callback::from(move |color: String| {
|
||||||
|
selected_color.set(Some(color));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_external_color_select = {
|
||||||
|
let external_selected_color = external_selected_color.clone();
|
||||||
|
Callback::from(move |color: String| {
|
||||||
|
external_selected_color.set(Some(color));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_create_submit = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
let description = description.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let create_error_message = create_error_message.clone();
|
||||||
|
let is_creating = is_creating.clone();
|
||||||
|
let on_create_calendar = props.on_create_calendar.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = (*calendar_name).trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
create_error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_creating.set(true);
|
||||||
|
create_error_message.set(None);
|
||||||
|
|
||||||
|
let desc = if description.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((*description).clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
on_create_calendar.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// External Calendar handlers
|
||||||
|
let on_external_submit = {
|
||||||
|
let external_name = external_name.clone();
|
||||||
|
let external_url = external_url.clone();
|
||||||
|
let external_selected_color = external_selected_color.clone();
|
||||||
|
let external_is_loading = external_is_loading.clone();
|
||||||
|
let external_error_message = external_error_message.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let on_external_success = props.on_external_success.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = (*external_name).trim().to_string();
|
||||||
|
let url = (*external_url).trim().to_string();
|
||||||
|
let color = (*external_selected_color).clone().unwrap_or_else(|| "#4285f4".to_string());
|
||||||
|
|
||||||
|
// Debug logging to understand the issue
|
||||||
|
web_sys::console::log_1(&format!("External calendar form submission - Name: '{}', URL: '{}'", name, url).into());
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
external_error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
web_sys::console::log_1(&"Validation failed: empty name".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if url.is_empty() {
|
||||||
|
external_error_message.set(Some("Calendar URL is required".to_string()));
|
||||||
|
web_sys::console::log_1(&"Validation failed: empty URL".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
external_error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
external_is_loading.set(true);
|
||||||
|
external_error_message.set(None);
|
||||||
|
|
||||||
|
let external_is_loading = external_is_loading.clone();
|
||||||
|
let external_error_message = external_error_message.clone();
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
let on_external_success = on_external_success.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
||||||
|
Ok(calendar) => {
|
||||||
|
external_is_loading.set(false);
|
||||||
|
on_close.emit(());
|
||||||
|
on_external_success.emit(calendar.id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
external_is_loading.set(false);
|
||||||
|
external_error_message.set(Some(format!("Failed to add calendar: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// External input change handlers
|
||||||
|
let on_external_name_change = {
|
||||||
|
let external_name = external_name.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
external_name.set(input.value());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_external_url_change = {
|
||||||
|
let external_url = external_url.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
external_url.set(input.value());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||||
|
<div class="modal-content calendar-management-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{"Add Calendar"}</h2>
|
||||||
|
<button class="modal-close" onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-management-tabs">
|
||||||
|
<button
|
||||||
|
class={if *active_tab == CalendarTab::Create { "tab-button active" } else { "tab-button" }}
|
||||||
|
onclick={
|
||||||
|
let on_tab_click = on_tab_click.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::Create))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{"Create Calendar"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={if *active_tab == CalendarTab::External { "tab-button active" } else { "tab-button" }}
|
||||||
|
onclick={
|
||||||
|
let on_tab_click = on_tab_click.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::External))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{"Add External"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{
|
||||||
|
match *active_tab {
|
||||||
|
CalendarTab::Create => html! {
|
||||||
|
<form onsubmit={on_create_submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-name">{"Calendar Name"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="calendar-name"
|
||||||
|
value={(*calendar_name).clone()}
|
||||||
|
oninput={on_name_change}
|
||||||
|
placeholder="Enter calendar name"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-description">{"Description (optional)"}</label>
|
||||||
|
<textarea
|
||||||
|
id="calendar-description"
|
||||||
|
value={(*description).clone()}
|
||||||
|
oninput={on_description_change}
|
||||||
|
placeholder="Enter calendar description"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Calendar Color"}</label>
|
||||||
|
<div class="color-picker">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let is_selected = selected_color.as_ref() == Some(color);
|
||||||
|
let color_clone = color.clone();
|
||||||
|
let on_color_select = on_color_select.clone();
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
key={color.clone()}
|
||||||
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
on_color_select.emit(color_clone.clone());
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(ref error) = *create_error_message {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-button"
|
||||||
|
onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="create-button"
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{if *is_creating { "Creating..." } else { "Create Calendar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
},
|
||||||
|
CalendarTab::External => html! {
|
||||||
|
<form onsubmit={on_external_submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-name">{"Calendar Name"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="external-name"
|
||||||
|
value={(*external_name).clone()}
|
||||||
|
onchange={on_external_name_change}
|
||||||
|
placeholder="Enter calendar name"
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-url">{"Calendar URL"}</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="external-url"
|
||||||
|
value={(*external_url).clone()}
|
||||||
|
onchange={on_external_url_change}
|
||||||
|
placeholder="https://example.com/calendar.ics"
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
/>
|
||||||
|
<small class="help-text">
|
||||||
|
{"Enter the ICS/CalDAV URL for your external calendar"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Calendar Color"}</label>
|
||||||
|
<div class="color-picker">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let is_selected = external_selected_color.as_ref() == Some(color);
|
||||||
|
let color_clone = color.clone();
|
||||||
|
let on_external_color_select = on_external_color_select.clone();
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
key={color.clone()}
|
||||||
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
on_external_color_select.emit(color_clone.clone());
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(ref error) = *external_error_message {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-button"
|
||||||
|
onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="create-button"
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
>
|
||||||
|
{if *external_is_loading { "Adding..." } else { "Add Calendar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ pub struct EventContextMenuProps {
|
|||||||
pub event: Option<VEvent>,
|
pub event: Option<VEvent>,
|
||||||
pub on_edit: Callback<EditAction>,
|
pub on_edit: Callback<EditAction>,
|
||||||
pub on_delete: Callback<DeleteAction>,
|
pub on_delete: Callback<DeleteAction>,
|
||||||
|
pub on_view_details: Callback<VEvent>,
|
||||||
pub on_close: Callback<()>,
|
pub on_close: Callback<()>,
|
||||||
|
pub on_edit_singleton: Callback<VEvent>, // New callback for editing singleton events without scope
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(EventContextMenu)]
|
#[function_component(EventContextMenu)]
|
||||||
@@ -91,6 +93,14 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
.map(|event| event.rrule.is_some())
|
.map(|event| event.rrule.is_some())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Check if the event is from an external calendar (read-only)
|
||||||
|
let is_external = props
|
||||||
|
.event
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|event| event.calendar_path.as_ref())
|
||||||
|
.map(|path| path.starts_with("external_"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let create_edit_callback = |action: EditAction| {
|
let create_edit_callback = |action: EditAction| {
|
||||||
let on_edit = props.on_edit.clone();
|
let on_edit = props.on_edit.clone();
|
||||||
let on_close = props.on_close.clone();
|
let on_close = props.on_close.clone();
|
||||||
@@ -100,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();
|
||||||
@@ -109,6 +131,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let create_view_details_callback = {
|
||||||
|
let on_view_details = props.on_view_details.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let event = props.event.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(event) = &event {
|
||||||
|
on_view_details.emit(event.clone());
|
||||||
|
}
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
@@ -116,7 +150,15 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
if is_recurring {
|
if is_external {
|
||||||
|
// External calendar events are read-only - only show "View Details"
|
||||||
|
html! {
|
||||||
|
<div class="context-menu-item" onclick={create_view_details_callback}>
|
||||||
|
{"View Event Details"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if is_recurring {
|
||||||
|
// Regular recurring events - show edit options
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||||
@@ -131,14 +173,17 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
if !is_external {
|
||||||
|
// Only show delete options for non-external events
|
||||||
if is_recurring {
|
if is_recurring {
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
@@ -160,6 +205,10 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No delete options for external events
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,17 +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 local date/times and timezone - no UTC conversion
|
||||||
|
let effective_end_date = if self.end_time == NaiveTime::from_hms_opt(0, 0, 0).unwrap() {
|
||||||
|
// If end time is midnight (00:00), treat it as beginning of next day
|
||||||
|
self.end_date + chrono::Duration::days(1)
|
||||||
|
} else {
|
||||||
|
self.end_date
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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(),
|
||||||
self.start_date.format("%Y-%m-%d").to_string(),
|
start_date,
|
||||||
self.start_time.format("%H:%M").to_string(),
|
start_time,
|
||||||
self.end_date.format("%Y-%m-%d").to_string(),
|
end_date,
|
||||||
self.end_time.format("%H:%M").to_string(),
|
end_time,
|
||||||
self.location.clone(),
|
self.location.clone(),
|
||||||
self.all_day,
|
self.all_day,
|
||||||
format!("{:?}", self.status).to_uppercase(),
|
format!("{:?}", self.status).to_uppercase(),
|
||||||
@@ -170,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
|
||||||
|
|||||||
@@ -24,23 +24,40 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
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();
|
||||||
let password_ref = use_node_ref();
|
let password_ref = use_node_ref();
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,12 +101,21 @@ 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();
|
||||||
let username = username.clone();
|
let username = username.clone();
|
||||||
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,11 +168,23 @@ 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);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
|
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
|
||||||
|
// Clear any existing invalid tokens
|
||||||
|
let _ = LocalStorage::delete("auth_token");
|
||||||
|
let _ = LocalStorage::delete("session_token");
|
||||||
|
let _ = LocalStorage::delete("caldav_credentials");
|
||||||
error_message.set(Some(err));
|
error_message.set(Some(err));
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
}
|
}
|
||||||
@@ -160,6 +200,7 @@ 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>
|
||||||
|
<div class="input-with-checkbox">
|
||||||
<input
|
<input
|
||||||
ref={server_url_ref}
|
ref={server_url_ref}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -168,20 +209,24 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
value={(*server_url).clone()}
|
value={(*server_url).clone()}
|
||||||
onchange={on_server_url_change}
|
onchange={on_server_url_change}
|
||||||
disabled={*is_loading}
|
disabled={*is_loading}
|
||||||
|
tabindex="1"
|
||||||
/>
|
/>
|
||||||
<div class="remember-checkbox">
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_server">{"Remember"}</label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="remember_server"
|
id="remember_server"
|
||||||
checked={*remember_server}
|
checked={*remember_server}
|
||||||
onchange={on_remember_server_change}
|
onchange={on_remember_server_change}
|
||||||
|
tabindex="4"
|
||||||
/>
|
/>
|
||||||
<label for="remember_server">{"Remember server"}</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">{"Username"}</label>
|
<label for="username">{"Username"}</label>
|
||||||
|
<div class="input-with-checkbox">
|
||||||
<input
|
<input
|
||||||
ref={username_ref}
|
ref={username_ref}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -190,29 +235,44 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
value={(*username).clone()}
|
value={(*username).clone()}
|
||||||
onchange={on_username_change}
|
onchange={on_username_change}
|
||||||
disabled={*is_loading}
|
disabled={*is_loading}
|
||||||
|
tabindex="2"
|
||||||
/>
|
/>
|
||||||
<div class="remember-checkbox">
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_username">{"Remember"}</label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="remember_username"
|
id="remember_username"
|
||||||
checked={*remember_username}
|
checked={*remember_username}
|
||||||
onchange={on_remember_username_change}
|
onchange={on_remember_username_change}
|
||||||
|
tabindex="5"
|
||||||
/>
|
/>
|
||||||
<label for="remember_username">{"Remember username"}</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">{"Password"}</label>
|
<label for="password">{"Password"}</label>
|
||||||
|
<div class="password-input-container">
|
||||||
<input
|
<input
|
||||||
ref={password_ref}
|
ref={password_ref}
|
||||||
type="password"
|
type={if *show_password { "text" } else { "password" }}
|
||||||
id="password"
|
id="password"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
value={(*password).clone()}
|
value={(*password).clone()}
|
||||||
onchange={on_password_change}
|
onchange={on_password_change}
|
||||||
disabled={*is_loading}
|
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>
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
96
frontend/src/components/mobile_warning_modal.rs
Normal file
96
frontend/src/components/mobile_warning_modal.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::window;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct MobileWarningModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(MobileWarningModal)]
|
||||||
|
pub fn mobile_warning_modal(props: &MobileWarningModalProps) -> Html {
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
let element = target.dyn_into::<web_sys::Element>().unwrap();
|
||||||
|
if element.class_list().contains("modal-overlay") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-overlay mobile-warning-overlay" onclick={on_backdrop_click}>
|
||||||
|
<div class="modal-content mobile-warning-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{"Desktop Application"}</h2>
|
||||||
|
<button class="modal-close" onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mobile-warning-icon">
|
||||||
|
{"💻"}
|
||||||
|
</div>
|
||||||
|
<p class="mobile-warning-title">
|
||||||
|
{"This calendar application is designed for desktop usage"}
|
||||||
|
</p>
|
||||||
|
<p class="mobile-warning-description">
|
||||||
|
{"For the best mobile calendar experience, we recommend using dedicated CalDAV apps available on your device's app store:"}
|
||||||
|
</p>
|
||||||
|
<ul class="mobile-warning-apps">
|
||||||
|
<li>
|
||||||
|
<strong>{"iOS:"}</strong>
|
||||||
|
{" Calendar (built-in), Calendars 5, Fantastical"}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>{"Android:"}</strong>
|
||||||
|
{" Google Calendar, DAVx5, CalDAV Sync"}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mobile-warning-note">
|
||||||
|
{"These apps can sync with the same CalDAV server you're using here."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="continue-anyway-button" onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}>
|
||||||
|
{"Continue Anyway"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to detect mobile devices
|
||||||
|
pub fn is_mobile_device() -> bool {
|
||||||
|
if let Some(window) = window() {
|
||||||
|
let navigator = window.navigator();
|
||||||
|
let user_agent = navigator.user_agent().unwrap_or_default();
|
||||||
|
let user_agent = user_agent.to_lowercase();
|
||||||
|
|
||||||
|
// Check for mobile device indicators
|
||||||
|
user_agent.contains("mobile")
|
||||||
|
|| user_agent.contains("android")
|
||||||
|
|| user_agent.contains("iphone")
|
||||||
|
|| user_agent.contains("ipad")
|
||||||
|
|| user_agent.contains("ipod")
|
||||||
|
|| user_agent.contains("blackberry")
|
||||||
|
|| user_agent.contains("webos")
|
||||||
|
|| user_agent.contains("opera mini")
|
||||||
|
|| user_agent.contains("windows phone")
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod calendar_context_menu;
|
pub mod calendar_context_menu;
|
||||||
|
pub mod calendar_management_modal;
|
||||||
pub mod calendar_header;
|
pub mod calendar_header;
|
||||||
pub mod calendar_list_item;
|
pub mod calendar_list_item;
|
||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
@@ -10,7 +11,9 @@ pub mod event_form;
|
|||||||
pub mod event_modal;
|
pub mod event_modal;
|
||||||
pub mod external_calendar_modal;
|
pub mod external_calendar_modal;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod mobile_warning_modal;
|
||||||
pub mod month_view;
|
pub mod month_view;
|
||||||
|
pub mod print_preview_modal;
|
||||||
pub mod recurring_edit_modal;
|
pub mod recurring_edit_modal;
|
||||||
pub mod route_handler;
|
pub mod route_handler;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
@@ -18,18 +21,19 @@ pub mod week_view;
|
|||||||
|
|
||||||
pub use calendar::Calendar;
|
pub use calendar::Calendar;
|
||||||
pub use calendar_context_menu::CalendarContextMenu;
|
pub use calendar_context_menu::CalendarContextMenu;
|
||||||
|
pub use calendar_management_modal::CalendarManagementModal;
|
||||||
pub use calendar_header::CalendarHeader;
|
pub use calendar_header::CalendarHeader;
|
||||||
pub use calendar_list_item::CalendarListItem;
|
pub use calendar_list_item::CalendarListItem;
|
||||||
pub use context_menu::ContextMenu;
|
pub use context_menu::ContextMenu;
|
||||||
pub use create_calendar_modal::CreateCalendarModal;
|
|
||||||
pub use create_event_modal::CreateEventModal;
|
pub use create_event_modal::CreateEventModal;
|
||||||
// Re-export event form types for backwards compatibility
|
// Re-export event form types for backwards compatibility
|
||||||
pub use event_form::EventCreationData;
|
pub use event_form::EventCreationData;
|
||||||
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||||
pub use event_modal::EventModal;
|
pub use event_modal::EventModal;
|
||||||
pub use external_calendar_modal::ExternalCalendarModal;
|
|
||||||
pub use login::Login;
|
pub use login::Login;
|
||||||
|
pub use mobile_warning_modal::MobileWarningModal;
|
||||||
pub use month_view::MonthView;
|
pub use month_view::MonthView;
|
||||||
|
pub use print_preview_modal::PrintPreviewModal;
|
||||||
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||||
pub use route_handler::RouteHandler;
|
pub use route_handler::RouteHandler;
|
||||||
pub use sidebar::{Sidebar, Theme, ViewMode};
|
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||||
|
|||||||
362
frontend/src/components/print_preview_modal.rs
Normal file
362
frontend/src/components/print_preview_modal.rs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
use crate::components::{ViewMode, WeekView, MonthView};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use wasm_bindgen::{closure::Closure, JsCast};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct PrintPreviewModalProps {
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub view_mode: ViewMode,
|
||||||
|
pub current_date: NaiveDate,
|
||||||
|
pub selected_date: NaiveDate,
|
||||||
|
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
pub time_increment: u32,
|
||||||
|
pub today: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
|
||||||
|
let start_hour = use_state(|| 6u32);
|
||||||
|
let end_hour = use_state(|| 22u32);
|
||||||
|
let zoom_level = use_state(|| 0.4f64); // Default 40% zoom
|
||||||
|
|
||||||
|
let close_modal = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if e.target() == e.current_target() {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_start_hour_change = {
|
||||||
|
let start_hour = start_hour.clone();
|
||||||
|
let end_hour = end_hour.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||||
|
if let Some(select) = target {
|
||||||
|
if let Ok(hour) = select.value().parse::<u32>() {
|
||||||
|
if hour < *end_hour {
|
||||||
|
start_hour.set(hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_end_hour_change = {
|
||||||
|
let start_hour = start_hour.clone();
|
||||||
|
let end_hour = end_hour.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||||
|
if let Some(select) = target {
|
||||||
|
if let Ok(hour) = select.value().parse::<u32>() {
|
||||||
|
if hour > *start_hour && hour <= 24 {
|
||||||
|
end_hour.set(hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let format_hour = |hour: u32| -> String {
|
||||||
|
if hour == 0 {
|
||||||
|
"12 AM".to_string()
|
||||||
|
} else if hour < 12 {
|
||||||
|
format!("{} AM", hour)
|
||||||
|
} else if hour == 12 {
|
||||||
|
"12 PM".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} PM", hour - 12)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate dynamic base unit for print preview
|
||||||
|
let calculate_print_dimensions = |start_hour: u32, end_hour: u32, time_increment: u32| -> (f64, f64, f64) {
|
||||||
|
let visible_hours = (end_hour - start_hour) as f64;
|
||||||
|
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
||||||
|
let header_height = 50.0; // Fixed week header height in print preview
|
||||||
|
let header_border = 2.0; // Week header bottom border (2px solid)
|
||||||
|
let container_spacing = 8.0; // Additional container spacing/margins
|
||||||
|
let total_overhead = header_height + header_border + container_spacing;
|
||||||
|
let available_height = 720.0 - total_overhead; // Available for time content
|
||||||
|
let base_unit = available_height / (visible_hours * slots_per_hour);
|
||||||
|
let pixels_per_hour = base_unit * slots_per_hour;
|
||||||
|
|
||||||
|
(base_unit, pixels_per_hour, available_height)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate print dimensions for the current hour range
|
||||||
|
let (base_unit, pixels_per_hour, _available_height) = calculate_print_dimensions(*start_hour, *end_hour, props.time_increment);
|
||||||
|
|
||||||
|
// Effect to update print copy whenever modal renders or content changes
|
||||||
|
{
|
||||||
|
let start_hour = *start_hour;
|
||||||
|
let end_hour = *end_hour;
|
||||||
|
let time_increment = props.time_increment;
|
||||||
|
let original_base_unit = base_unit;
|
||||||
|
use_effect(move || {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
// Set CSS variables on document root
|
||||||
|
if let Some(document_element) = document.document_element() {
|
||||||
|
if let Some(html_element) = document_element.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
let style = html_element.style();
|
||||||
|
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
|
||||||
|
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy content from print-preview-content to the hidden print-preview-copy div
|
||||||
|
let copy_content = move || {
|
||||||
|
if let Some(preview_content) = document.query_selector(".print-preview-content").ok().flatten() {
|
||||||
|
if let Some(print_copy) = document.get_element_by_id("print-preview-copy") {
|
||||||
|
// Clone the preview content
|
||||||
|
if let Some(content_clone) = preview_content.clone_node_with_deep(true).ok() {
|
||||||
|
// Clear the print copy div and add the cloned content
|
||||||
|
print_copy.set_inner_html("");
|
||||||
|
let _ = print_copy.append_child(&content_clone);
|
||||||
|
|
||||||
|
// Get the actual rendered height of the print copy div and recalculate base-unit
|
||||||
|
if let Some(print_copy_html) = print_copy.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
// Temporarily make visible to measure height, then hide again
|
||||||
|
let original_display = print_copy_html.style().get_property_value("display").unwrap_or_default();
|
||||||
|
let _ = print_copy_html.style().set_property("display", "block");
|
||||||
|
let _ = print_copy_html.style().set_property("visibility", "hidden");
|
||||||
|
let _ = print_copy_html.style().set_property("position", "absolute");
|
||||||
|
let _ = print_copy_html.style().set_property("top", "-9999px");
|
||||||
|
|
||||||
|
// Now measure the height
|
||||||
|
let actual_height = print_copy_html.client_height() as f64;
|
||||||
|
|
||||||
|
// Restore original display
|
||||||
|
let _ = print_copy_html.style().set_property("display", &original_display);
|
||||||
|
let _ = print_copy_html.style().remove_property("visibility");
|
||||||
|
let _ = print_copy_html.style().remove_property("position");
|
||||||
|
let _ = print_copy_html.style().remove_property("top");
|
||||||
|
|
||||||
|
// Recalculate base-unit and pixels-per-hour based on actual height
|
||||||
|
let visible_hours = (end_hour - start_hour) as f64;
|
||||||
|
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
||||||
|
let header_height = 50.0;
|
||||||
|
let header_border = 2.0;
|
||||||
|
let container_spacing = 8.0;
|
||||||
|
let total_overhead = header_height + header_border + container_spacing;
|
||||||
|
let available_height = actual_height - total_overhead;
|
||||||
|
let actual_base_unit = available_height / (visible_hours * slots_per_hour);
|
||||||
|
let actual_pixels_per_hour = actual_base_unit * slots_per_hour;
|
||||||
|
|
||||||
|
|
||||||
|
// Set CSS variables with recalculated values
|
||||||
|
let style = print_copy_html.style();
|
||||||
|
let _ = style.set_property("--print-base-unit", &format!("{:.2}", actual_base_unit));
|
||||||
|
let _ = style.set_property("--print-pixels-per-hour", &format!("{:.2}", actual_pixels_per_hour));
|
||||||
|
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
|
||||||
|
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
|
||||||
|
|
||||||
|
// Copy data attributes
|
||||||
|
let _ = print_copy.set_attribute("data-start-hour", &start_hour.to_string());
|
||||||
|
let _ = print_copy.set_attribute("data-end-hour", &end_hour.to_string());
|
||||||
|
|
||||||
|
// Recalculate event positions using the new base-unit
|
||||||
|
let events = print_copy.query_selector_all(".week-event").unwrap();
|
||||||
|
let scale_factor = actual_base_unit / original_base_unit;
|
||||||
|
|
||||||
|
for i in 0..events.length() {
|
||||||
|
if let Some(event_element) = events.get(i) {
|
||||||
|
if let Some(event_html) = event_element.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
let event_style = event_html.style();
|
||||||
|
|
||||||
|
// Get current positioning values and recalculate
|
||||||
|
if let Ok(current_top) = event_style.get_property_value("top") {
|
||||||
|
if current_top.ends_with("px") {
|
||||||
|
if let Ok(top_px) = current_top[..current_top.len()-2].parse::<f64>() {
|
||||||
|
let new_top = top_px * scale_factor;
|
||||||
|
let _ = event_style.set_property("top", &format!("{:.2}px", new_top));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(current_height) = event_style.get_property_value("height") {
|
||||||
|
if current_height.ends_with("px") {
|
||||||
|
if let Ok(height_px) = current_height[..current_height.len()-2].parse::<f64>() {
|
||||||
|
let new_height = height_px * scale_factor;
|
||||||
|
let _ = event_style.set_property("height", &format!("{:.2}px", new_height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
web_sys::console::log_1(&format!("Height: {:.2}, Original base-unit: {:.2}, New base-unit: {:.2}, Scale factor: {:.2}",
|
||||||
|
actual_height, original_base_unit, actual_base_unit, scale_factor).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy content immediately
|
||||||
|
copy_content();
|
||||||
|
|
||||||
|
// Also set up a small delay to catch any async rendering
|
||||||
|
let copy_callback = Closure::wrap(Box::new(copy_content) as Box<dyn FnMut()>);
|
||||||
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
copy_callback.as_ref().unchecked_ref(),
|
||||||
|
100
|
||||||
|
);
|
||||||
|
copy_callback.forget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_print = {
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
// Print copy is already updated by the use_effect, just trigger print
|
||||||
|
let _ = window.print();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop print-preview-modal-backdrop" onclick={backdrop_click}>
|
||||||
|
<div class="modal-content print-preview-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{"Print Preview"}</h3>
|
||||||
|
<button class="modal-close" onclick={close_modal.clone()}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body print-preview-body">
|
||||||
|
<div class="print-preview-controls">
|
||||||
|
{
|
||||||
|
if props.view_mode == ViewMode::Week {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="start-hour">{"Start Hour:"}</label>
|
||||||
|
<select id="start-hour" onchange={on_start_hour_change}>
|
||||||
|
{
|
||||||
|
(0..24).map(|hour| {
|
||||||
|
html! {
|
||||||
|
<option value={hour.to_string()} selected={hour == *start_hour}>
|
||||||
|
{format_hour(hour)}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="end-hour">{"End Hour:"}</label>
|
||||||
|
<select id="end-hour" onchange={on_end_hour_change}>
|
||||||
|
{
|
||||||
|
(1..=24).map(|hour| {
|
||||||
|
html! {
|
||||||
|
<option value={hour.to_string()} selected={hour == *end_hour}>
|
||||||
|
{if hour == 24 { "12 AM".to_string() } else { format_hour(hour) }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hour-range-info">
|
||||||
|
{format!("Will print from {} to {}",
|
||||||
|
format_hour(*start_hour),
|
||||||
|
if *end_hour == 24 { "12 AM".to_string() } else { format_hour(*end_hour) }
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<div class="month-info">
|
||||||
|
{"Will print entire month view"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="zoom-display-info">
|
||||||
|
<label>{"Zoom: "}</label>
|
||||||
|
<span>{format!("{}%", (*zoom_level * 100.0) as i32)}</span>
|
||||||
|
<span class="zoom-hint">{"(scroll to zoom)"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-actions">
|
||||||
|
<button class="btn-primary" onclick={on_print}>{"Print"}</button>
|
||||||
|
<button class="btn-secondary" onclick={close_modal}>{"Cancel"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="print-preview-display" onwheel={{
|
||||||
|
let zoom_level = zoom_level.clone();
|
||||||
|
Callback::from(move |e: WheelEvent| {
|
||||||
|
e.prevent_default(); // Prevent page scroll
|
||||||
|
let delta_y = e.delta_y();
|
||||||
|
let zoom_change = if delta_y < 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||||
|
let new_zoom = (*zoom_level * zoom_change).clamp(0.2, 1.5);
|
||||||
|
zoom_level.set(new_zoom);
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<div class="print-preview-paper"
|
||||||
|
data-start-hour={start_hour.to_string()}
|
||||||
|
data-end-hour={end_hour.to_string()}
|
||||||
|
style={format!(
|
||||||
|
"--print-start-hour: {}; --print-end-hour: {}; --print-base-unit: {:.2}; --print-pixels-per-hour: {:.2}; transform: scale({}); transform-origin: top center;",
|
||||||
|
*start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level
|
||||||
|
)}>
|
||||||
|
<div class="print-preview-content">
|
||||||
|
{
|
||||||
|
match props.view_mode {
|
||||||
|
ViewMode::Week => html! {
|
||||||
|
<WeekView
|
||||||
|
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
|
||||||
|
current_date={props.current_date}
|
||||||
|
today={props.today}
|
||||||
|
events={props.events.clone()}
|
||||||
|
on_event_click={Callback::noop()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
time_increment={props.time_increment}
|
||||||
|
print_mode={true}
|
||||||
|
print_pixels_per_hour={Some(pixels_per_hour)}
|
||||||
|
print_start_hour={Some(*start_hour)}
|
||||||
|
/>
|
||||||
|
},
|
||||||
|
ViewMode::Month => html! {
|
||||||
|
<MonthView
|
||||||
|
key={format!("month-preview-{}-{}", *start_hour, *end_hour)}
|
||||||
|
current_month={props.current_date}
|
||||||
|
selected_date={Some(props.selected_date)}
|
||||||
|
today={props.today}
|
||||||
|
events={props.events.clone()}
|
||||||
|
on_day_select={None::<Callback<NaiveDate>>}
|
||||||
|
on_event_click={Callback::noop()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
/>
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
)>,
|
)>,
|
||||||
|
|||||||
@@ -100,8 +100,7 @@ impl Default for ViewMode {
|
|||||||
pub struct SidebarProps {
|
pub struct SidebarProps {
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
pub on_logout: Callback<()>,
|
pub on_logout: Callback<()>,
|
||||||
pub on_create_calendar: Callback<()>,
|
pub on_add_calendar: Callback<()>,
|
||||||
pub on_create_external_calendar: Callback<()>,
|
|
||||||
pub external_calendars: Vec<ExternalCalendar>,
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
pub on_external_calendar_toggle: Callback<i32>,
|
pub on_external_calendar_toggle: Callback<i32>,
|
||||||
pub on_external_calendar_delete: Callback<i32>,
|
pub on_external_calendar_delete: Callback<i32>,
|
||||||
@@ -110,6 +109,7 @@ pub struct SidebarProps {
|
|||||||
pub on_color_change: Callback<(String, String)>,
|
pub on_color_change: Callback<(String, String)>,
|
||||||
pub on_color_picker_toggle: Callback<String>,
|
pub on_color_picker_toggle: Callback<String>,
|
||||||
pub available_colors: Vec<String>,
|
pub available_colors: Vec<String>,
|
||||||
|
pub refreshing_calendar_id: Option<i32>,
|
||||||
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||||
pub on_calendar_visibility_toggle: Callback<String>,
|
pub on_calendar_visibility_toggle: Callback<String>,
|
||||||
pub current_view: ViewMode,
|
pub current_view: ViewMode,
|
||||||
@@ -203,9 +203,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
|
||||||
<Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>>
|
|
||||||
</nav>
|
|
||||||
{
|
{
|
||||||
if let Some(ref info) = props.user_info {
|
if let Some(ref info) = props.user_info {
|
||||||
if !info.calendars.is_empty() {
|
if !info.calendars.is_empty() {
|
||||||
@@ -259,7 +256,11 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
html! {
|
html! {
|
||||||
<li class="external-calendar-item" style="position: relative;">
|
<li class="external-calendar-item" style="position: relative;">
|
||||||
<div
|
<div
|
||||||
class="external-calendar-info"
|
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
||||||
|
"external-calendar-info color-picker-active"
|
||||||
|
} else {
|
||||||
|
"external-calendar-info"
|
||||||
|
}}
|
||||||
oncontextmenu={{
|
oncontextmenu={{
|
||||||
let on_context_menu = on_external_calendar_context_menu.clone();
|
let on_context_menu = on_external_calendar_context_menu.clone();
|
||||||
let cal_id = cal.id;
|
let cal_id = cal.id;
|
||||||
@@ -276,7 +277,48 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
<span
|
<span
|
||||||
class="external-calendar-color"
|
class="external-calendar-color"
|
||||||
style={format!("background-color: {}", cal.color)}
|
style={format!("background-color: {}", cal.color)}
|
||||||
|
onclick={{
|
||||||
|
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
|
||||||
|
let external_id = format!("external_{}", cal.id);
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_color_picker_toggle.emit(external_id.clone());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
||||||
|
html! {
|
||||||
|
<div class="color-picker-dropdown">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let color_str = color.clone();
|
||||||
|
let external_id = format!("external_{}", cal.id);
|
||||||
|
let on_color_change = props.on_color_change.clone();
|
||||||
|
|
||||||
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||||
|
on_color_change.emit((external_id.clone(), color_str.clone()));
|
||||||
|
});
|
||||||
|
|
||||||
|
let is_selected = cal.color == *color;
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
key={color.clone()}
|
||||||
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={on_color_select}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
<span class="external-calendar-name">{&cal.name}</span>
|
<span class="external-calendar-name">{&cal.name}</span>
|
||||||
<div class="external-calendar-actions">
|
<div class="external-calendar-actions">
|
||||||
{
|
{
|
||||||
@@ -304,8 +346,15 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
on_refresh.emit(cal_id);
|
on_refresh.emit(cal_id);
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
disabled={props.refreshing_calendar_id == Some(cal.id)}
|
||||||
>
|
>
|
||||||
{"🔄"}
|
{
|
||||||
|
if props.refreshing_calendar_id == Some(cal.id) {
|
||||||
|
html! { <i class="fas fa-spinner fa-spin"></i> }
|
||||||
|
} else {
|
||||||
|
html! { <i class="fas fa-sync-alt"></i> }
|
||||||
|
}
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,12 +401,8 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
<button onclick={props.on_add_calendar.reform(|_| ())} class="add-calendar-button">
|
||||||
{"+ Create Calendar"}
|
{"+ Add Calendar"}
|
||||||
</button>
|
|
||||||
|
|
||||||
<button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button">
|
|
||||||
{"+ Add External Calendar"}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="view-selector">
|
<div class="view-selector">
|
||||||
|
|||||||
@@ -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>,
|
||||||
)>,
|
)>,
|
||||||
@@ -42,6 +42,12 @@ pub struct WeekViewProps {
|
|||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub time_increment: u32,
|
pub time_increment: u32,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub print_mode: bool,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub print_pixels_per_hour: Option<f64>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub print_start_hour: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
@@ -81,6 +87,31 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
||||||
|
|
||||||
|
// Current time state for time indicator
|
||||||
|
let current_time = use_state(|| Local::now());
|
||||||
|
|
||||||
|
// Update current time every 5 seconds
|
||||||
|
{
|
||||||
|
let current_time = current_time.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
let interval = gloo_timers::callback::Interval::new(5_000, move || {
|
||||||
|
current_time.set(Local::now());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the interval to keep it alive
|
||||||
|
move || drop(interval)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to calculate current time indicator position
|
||||||
|
let calculate_current_time_position = |time_increment: u32| -> f64 {
|
||||||
|
let now = current_time.time();
|
||||||
|
let hour = now.hour() as f64;
|
||||||
|
let minute = now.minute() as f64;
|
||||||
|
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
|
||||||
|
(hour + minute / 60.0) * pixels_per_hour
|
||||||
|
};
|
||||||
|
|
||||||
// Helper function to get calendar color for an event
|
// Helper function to get calendar color for an event
|
||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
@@ -254,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
|
||||||
@@ -286,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
|
||||||
));
|
));
|
||||||
@@ -413,13 +440,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Time labels
|
// Time labels
|
||||||
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||||
{
|
{
|
||||||
time_labels.iter().map(|time| {
|
time_labels.iter().enumerate().map(|(hour, time)| {
|
||||||
let is_quarter_mode = props.time_increment == 15;
|
let is_quarter_mode = props.time_increment == 15;
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!(
|
<div class={classes!(
|
||||||
"time-label",
|
"time-label",
|
||||||
if is_quarter_mode { Some("quarter-mode") } else { None }
|
if is_quarter_mode { Some("quarter-mode") } else { None }
|
||||||
)}>
|
)} data-hour={hour.to_string()}>
|
||||||
{time}
|
{time}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -586,10 +613,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);
|
||||||
@@ -620,8 +646,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);
|
||||||
|
|
||||||
@@ -676,10 +702,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
>
|
>
|
||||||
// Time slot backgrounds - 24 hour slots to represent full day
|
// Time slot backgrounds - 24 hour slots to represent full day
|
||||||
{
|
{
|
||||||
(0..24).map(|_hour| {
|
(0..24).map(|hour| {
|
||||||
let slots_per_hour = 60 / props.time_increment;
|
let slots_per_hour = 60 / props.time_increment;
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })} data-hour={hour.to_string()}>
|
||||||
{
|
{
|
||||||
(0..slots_per_hour).map(|_slot| {
|
(0..slots_per_hour).map(|_slot| {
|
||||||
let slot_class = if props.time_increment == 15 {
|
let slot_class = if props.time_increment == 15 {
|
||||||
@@ -701,7 +727,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
<div class="events-container">
|
<div class="events-container">
|
||||||
{
|
{
|
||||||
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
||||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment);
|
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||||
|
|
||||||
// Skip all-day events (they're rendered in the header)
|
// Skip all-day events (they're rendered in the header)
|
||||||
if is_all_day {
|
if is_all_day {
|
||||||
@@ -730,6 +756,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let event_for_drag = event.clone();
|
let event_for_drag = event.clone();
|
||||||
let date_for_drag = *date;
|
let date_for_drag = *date;
|
||||||
let time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
|
let print_pixels_per_hour = props.print_pixels_per_hour;
|
||||||
|
let print_start_hour = props.print_start_hour;
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
||||||
|
|
||||||
@@ -743,7 +771,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
||||||
|
|
||||||
// Get event's current position in day column coordinates
|
// Get event's current position in day column coordinates
|
||||||
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment);
|
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment, print_pixels_per_hour, print_start_hour);
|
||||||
let event_start_pixels = event_start_pixels as f64;
|
let event_start_pixels = event_start_pixels as f64;
|
||||||
|
|
||||||
// Convert click position to day column coordinates
|
// Convert click position to day column coordinates
|
||||||
@@ -794,9 +822,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;
|
||||||
@@ -1023,14 +1051,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);
|
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;
|
||||||
@@ -1056,10 +1083,10 @@ 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);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||||
|
|
||||||
let new_end_pixels = drag.current_y;
|
let new_end_pixels = drag.current_y;
|
||||||
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
||||||
@@ -1089,6 +1116,29 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Current time indicator - only show on today
|
||||||
|
{
|
||||||
|
if *date == props.today {
|
||||||
|
let current_time_position = calculate_current_time_position(props.time_increment);
|
||||||
|
let current_time_str = current_time.time().format("%I:%M %p").to_string();
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="current-time-indicator-container">
|
||||||
|
<div
|
||||||
|
class="current-time-indicator"
|
||||||
|
style={format!("top: {}px;", current_time_position)}
|
||||||
|
>
|
||||||
|
<div class="current-time-dot"></div>
|
||||||
|
<div class="current-time-line"></div>
|
||||||
|
<div class="current-time-label">{current_time_str}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
@@ -1170,18 +1220,15 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
|
|||||||
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: 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
|
// Events should display based on their local date, since we now store proper UTC times
|
||||||
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
|
// Convert the UTC stored time back to local time to determine display date
|
||||||
// but should still display on Sunday's column since that's when the user sees it
|
let event_date = local_start.date();
|
||||||
let should_display_here = event_date == date ||
|
|
||||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
|
|
||||||
|
|
||||||
if !should_display_here {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,32 +1237,44 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
|
|||||||
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate start position in pixels from midnight
|
// Calculate start position in pixels
|
||||||
let start_hour = local_start.hour() as f32;
|
let start_hour = local_start.hour() as f32;
|
||||||
let start_minute = local_start.minute() as f32;
|
let start_minute = local_start.minute() as f32;
|
||||||
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
|
let pixels_per_hour = if let Some(print_pph) = print_pixels_per_hour {
|
||||||
let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour;
|
print_pph as f32 // Use the dynamic print mode calculation
|
||||||
|
} else {
|
||||||
|
if time_increment == 15 { 120.0 } else { 60.0 } // Default values
|
||||||
|
};
|
||||||
|
|
||||||
|
// In print mode, offset by the start hour to show relative position within visible range
|
||||||
|
let hour_offset = if let Some(print_start) = print_start_hour {
|
||||||
|
print_start as f32
|
||||||
|
} else {
|
||||||
|
0.0 // No offset for normal view (starts at midnight)
|
||||||
|
};
|
||||||
|
|
||||||
|
let start_pixels = ((start_hour + start_minute / 60.0) - hour_offset) * pixels_per_hour;
|
||||||
|
|
||||||
// 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 {
|
||||||
// Event continues past midnight, cap at 24:00
|
// Event continues past midnight, cap at end of visible range
|
||||||
let max_pixels = 24.0 * pixels_per_hour;
|
let max_hour = if let Some(_print_start) = print_start_hour { 24.0 } else { 24.0 };
|
||||||
max_pixels - start_pixels
|
let max_pixels = (max_hour - hour_offset) * pixels_per_hour;
|
||||||
|
(max_pixels - start_pixels).max(20.0)
|
||||||
} else {
|
} else {
|
||||||
let end_hour = local_end.hour() as f32;
|
let end_hour = local_end.hour() as f32;
|
||||||
let end_minute = local_end.minute() as f32;
|
let end_minute = local_end.minute() as f32;
|
||||||
let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour;
|
let end_pixels = ((end_hour + end_minute / 60.0) - hour_offset) * pixels_per_hour;
|
||||||
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pixels_per_hour // Default 1 hour if no end time
|
pixels_per_hour // Default 1 hour if no end time
|
||||||
};
|
};
|
||||||
|
|
||||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,16 +1285,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
|
||||||
};
|
};
|
||||||
@@ -1256,9 +1315,9 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (_, _, _) = calculate_event_position(event, date, time_increment);
|
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))
|
||||||
@@ -1269,7 +1328,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)
|
||||||
@@ -1294,7 +1353,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();
|
||||||
@@ -1342,19 +1401,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;
|
||||||
@@ -37,6 +37,12 @@ pub struct UserInfo {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub calendars: Vec<CalendarInfo>,
|
pub calendars: Vec<CalendarInfo>,
|
||||||
|
#[serde(default = "default_timestamp")]
|
||||||
|
pub last_updated: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_timestamp() -> u64 {
|
||||||
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -269,14 +275,60 @@ impl CalendarService {
|
|||||||
grouped
|
grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert UTC events to local timezone for display
|
||||||
|
fn convert_utc_to_local(mut event: VEvent) -> VEvent {
|
||||||
|
// 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 {
|
||||||
|
web_sys::console::log_1(&format!(
|
||||||
|
"🕐 Converting UTC event '{}' to local time",
|
||||||
|
event.summary.as_deref().unwrap_or("Untitled")
|
||||||
|
).into());
|
||||||
|
|
||||||
|
// 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(
|
web_sys::console::log_1(
|
||||||
&format!(
|
&format!(
|
||||||
@@ -366,17 +418,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(),
|
||||||
@@ -389,7 +442,7 @@ impl CalendarService {
|
|||||||
web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into());
|
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;
|
||||||
|
|
||||||
@@ -416,8 +469,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;
|
||||||
@@ -549,7 +602,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();
|
||||||
@@ -559,7 +612,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 =
|
||||||
@@ -617,8 +670,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;
|
||||||
|
|
||||||
@@ -669,7 +722,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();
|
||||||
@@ -685,7 +738,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;
|
||||||
@@ -743,9 +796,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
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -786,14 +837,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;
|
||||||
@@ -824,9 +875,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
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -865,7 +914,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();
|
||||||
@@ -881,7 +930,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;
|
||||||
|
|
||||||
@@ -924,9 +973,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
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1251,9 +1298,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")?;
|
||||||
|
|
||||||
@@ -1284,10 +1333,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)
|
||||||
@@ -1311,7 +1361,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)
|
||||||
@@ -1388,10 +1439,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(
|
||||||
@@ -1415,10 +1467,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
|
||||||
}
|
}
|
||||||
@@ -1445,10 +1498,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")?;
|
||||||
|
|
||||||
@@ -1476,11 +1530,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);
|
||||||
|
|
||||||
@@ -1681,11 +1735,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")?;
|
||||||
|
|
||||||
@@ -1712,12 +1768,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);
|
||||||
|
|||||||
1309
frontend/styles.css
1309
frontend/styles.css
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
|||||||
/* Base Styles - Always Loaded */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-layout {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base Layout */
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 280px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Basic Form Elements */
|
|
||||||
input, select, textarea, button {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility Classes */
|
|
||||||
.loading {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success {
|
|
||||||
color: #28a745;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user