fix(calendar): stable content-based raw_uid for UID-less events; UTC dedup keys

parse_vevents used to leave raw_uid: None for any VEVENT with no UID
line, which the storage layer's unique index exempts from dedup -- so
that event duplicated on every re-import forever. Falls back to
content_uid (subject|organizer|starts_at|ends_at) so re-parsing the same
source resolves to the same key. Also fixes ymd_digits, the per-occurrence
dedup-key suffix for expanded recurring events, to format in UTC instead
of the machine's local timezone -- it could otherwise compute a different
date (and therefore a different key) for the same occurrence across a DST
transition or timezone change between imports.
This commit is contained in:
iamdoubz committed 2026-07-10 19:22:18 -05:00
1 parent e7b951cb90
commit 3214bd2740
1 file changed
+87 -5
+87 -5
View File
@@ -460,6 +460,17 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
}
}
_ => {
// Bug fix: a VEVENT with no UID line used to import
// with `raw_uid: None`, which the dedup unique index
// (source, raw_uid) explicitly exempts (`WHERE
// raw_uid IS NOT NULL`) — so every re-import created
// a brand-new duplicate row for it forever. Fall back
// to a deterministic hash of the event's own content
// so re-imports of the same source still resolve to
// the same key and upsert instead of duplicating.
let raw_uid = uid.take().or_else(|| {
Some(content_uid(&summary, &organizer, starts_at, ends_at))
});
events.push(ImportedEvent {
event: CalendarEvent {
id: uuid::Uuid::new_v4().to_string(),
@@ -469,7 +480,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
starts_at,
ends_at,
description: description.take(),
raw_uid: uid.take(),
raw_uid,
},
attendees,
});
@@ -615,12 +626,37 @@ fn local_to_utc_secs(dt: chrono::NaiveDateTime) -> i64 {
}
}
/// Deterministic stand-in identity for a VEVENT that has no `UID` of its own
/// (see the `_` arm of `parse_vevents` above), so re-running the same import
/// resolves to the same `raw_uid` and upserts in place rather than mints a
/// fresh duplicate row every time. Format matches migration
/// `0008_calendar_dedup_cleanup.sql`'s SQL-side backfill exactly, so a
/// re-import after that cleanup migration converges onto the same row it
/// already collapsed duplicates into rather than minting one more.
pub(crate) fn content_uid(
subject: &Option<String>,
organizer: &Option<String>,
starts_at: Option<i64>,
ends_at: Option<i64>,
) -> String {
format!(
"content:{}|{}|{}|{}",
subject.as_deref().unwrap_or(""),
organizer.as_deref().unwrap_or(""),
starts_at.unwrap_or(0),
ends_at.unwrap_or(0),
)
}
/// Bug fix: this used to convert to the machine's *local* timezone before
/// formatting, so an occurrence's date (and therefore its dedup key, see
/// `raw_uid` below) could come out differently on two imports run either
/// side of a DST transition or a timezone change — silently producing a
/// second row for the same occurrence on re-import. UTC is stable no matter
/// when/where the import runs.
fn ymd_digits(unix_secs: i64) -> String {
match Utc.timestamp_opt(unix_secs, 0).single() {
Some(dt) => {
let d = dt.with_timezone(&Local).date_naive();
format!("{:04}{:02}{:02}", d.year(), d.month(), d.day())
}
Some(dt) => format!("{:04}{:02}{:02}", dt.year(), dt.month(), dt.day()),
None => String::new(),
}
}
@@ -1082,4 +1118,50 @@ END:VEVENT\r\n";
assert_eq!(e.event.ends_at.unwrap() - e.event.starts_at.unwrap(), 1800);
}
}
/// Regression for the duplicate-import bug: a VEVENT with no `UID` line
/// used to get `raw_uid: None`, which the storage layer's unique index
/// exempts from dedup entirely -- so re-importing the same source
/// duplicated it on every single run. It must now get a stable,
/// content-derived `raw_uid` so re-parsing the identical source resolves
/// to the same key.
#[test]
fn parse_vevents_gives_a_uid_less_event_a_stable_content_based_raw_uid() {
let ics = "BEGIN:VEVENT\r\n\
SUMMARY:No UID here\r\n\
DTSTART:20260112T163000Z\r\n\
DTEND:20260112T170000Z\r\n\
END:VEVENT\r\n";
let first = parse_vevents(ics, "pst");
let second = parse_vevents(ics, "pst");
assert_eq!(first.len(), 1);
assert_eq!(second.len(), 1);
assert!(first[0].event.raw_uid.is_some());
assert_eq!(first[0].event.raw_uid, second[0].event.raw_uid);
}
/// Distinct UID-less events (different subjects) must not collide onto
/// the same content-based key.
#[test]
fn content_based_raw_uid_differs_for_distinct_uid_less_events() {
let ics_a = "BEGIN:VEVENT\r\nSUMMARY:Event A\r\nDTSTART:20260112T163000Z\r\nDTEND:20260112T170000Z\r\nEND:VEVENT\r\n";
let ics_b = "BEGIN:VEVENT\r\nSUMMARY:Event B\r\nDTSTART:20260112T163000Z\r\nDTEND:20260112T170000Z\r\nEND:VEVENT\r\n";
let a = parse_vevents(ics_a, "pst");
let b = parse_vevents(ics_b, "pst");
assert_ne!(a[0].event.raw_uid, b[0].event.raw_uid);
}
#[test]
fn ymd_digits_is_timezone_independent() {
// A UTC midnight timestamp must format to the same UTC calendar date
// regardless of the machine's local timezone (the original bug:
// this used to convert to `Local` first, so the same recurring
// occurrence could compute a different dedup-key suffix on a machine
// in a different timezone, or after a DST transition).
let utc_new_year = Utc
.with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
.unwrap()
.timestamp();
assert_eq!(ymd_digits(utc_new_year), "20260101");
}
}