diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 829a9e0..cb62305 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -18,6 +18,7 @@ import ( "sort" "strings" "time" + "unicode" ) // Fact sources. A calendar event reaches the store as a @@ -153,14 +154,20 @@ func Overlapping(events []Event, from, to time.Time) []Event { return out } -// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics -// and dashes). Non-Latin summaries collapse to their punctuation, which is why -// the day prefix carries the identity and this only disambiguates within a day. +// safeKey makes a summary safe to use inside a fact key: letters and digits in +// any script, plus dashes, with space and underscore folded to a dash. +// +// It kept ASCII only until 04-08-2026, and dropped everything else. His +// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to +// "--" and produced the same key on the same day — the second event of the day +// silently overwrote the first (Vikunja #443). Letting the letters through is +// what makes the key identify the event. Migration #18 drops the keys written +// under the old rule; they are re-derived on the next poll. func safeKey(s string) string { var b strings.Builder for _, r := range s { switch { - case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-': + case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-': b.WriteRune(r) case r == ' ' || r == '_': b.WriteRune('-') diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index a7ffdbb..e701176 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -139,6 +139,9 @@ func TestSafeKey(t *testing.T) { {"Hello_World", "Hello-World"}, {"special@#$chars!!", "specialchars"}, {"ALL_CAPS_123", "ALL-CAPS-123"}, + // His calendar is Russian. These reduced to "--" and "--" (Vikunja #443). + {"Встреча с Аней", "Встреча-с-Аней"}, + {"Обед с мамой", "Обед-с-мамой"}, } for _, tt := range tests { if got := safeKey(tt.in); got != tt.want { @@ -263,3 +266,19 @@ func TestSourceTrust(t *testing.T) { t.Errorf("Sources() = %v", Sources()) } } + +// Two Russian events on one day must not share a key. They did: safeKey kept +// ASCII only, so both summaries collapsed to their spaces and the second event +// overwrote the first in the store (Vikunja #443). +func TestFactKeyDistinguishesRussianEventsOnOneDay(t *testing.T) { + day := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) + a := Event{Summary: "Встреча с Аней", Start: day.Add(10 * time.Hour), End: day.Add(11 * time.Hour)} + b := Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(14 * time.Hour)} + if FactKeyIn(a, time.UTC) == FactKeyIn(b, time.UTC) { + t.Fatalf("both events keyed as %q", FactKeyIn(a, time.UTC)) + } + // The day prefix still has to survive, because the store range-scans on it. + if !strings.HasPrefix(FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) { + t.Fatalf("key %q lost the day prefix %q", FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) + } +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index a266efe..8a94f64 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -208,6 +208,17 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2 // list_tasks into something that writes without the row changing by one // byte. The fingerprint is the declared shape at approval time, so a // redefinition is a re-approval instead of a silent upgrade. + `DELETE FROM facts + WHERE key LIKE 'calendar_event_%' + AND replace(substr(key, 25), '-', '') = '';`, + // #18 — drop the calendar keys written while safeKey dropped Cyrillic + // (Vikunja #443). Everything after the date prefix was punctuation, so + // every Russian event on one day shared one key and only the last one + // survived. Deleting rather than rewriting: a calendar fact is derived + // data, the next poll writes the day again under keys that identify the + // event, and the old rows would otherwise be recited as extra meetings. + // The filter is exact — it keeps any key whose summary part still has a + // letter or a digit in it. } // migrate applies every migration with a number greater than the DB's current diff --git a/internal/store/migrations_test.go b/internal/store/migrations_test.go index e561413..e0b2663 100644 --- a/internal/store/migrations_test.go +++ b/internal/store/migrations_test.go @@ -47,3 +47,36 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) { t.Fatalf("after re-migrate user_version = %d, want %d", v, want) } } + +// Migration #18 clears the calendar keys written while safeKey dropped +// Cyrillic. Those rows are indistinguishable from real events on read, so +// leaving them would recite one meeting as several (Vikunja #443). +func TestCollapsedCalendarKeysAreDropped(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + rows := []string{ + "calendar_event_20260804_--", // "Встреча с Аней" under the old rule + "calendar_event_20260804_", // a one-word Russian summary + "calendar_event_20260804_Встреча-с-Аней", // the new format + "calendar_event_20260804_Standup", // an ASCII summary, always fine + } + for _, key := range rows { + if _, err := s.db.ExecContext(ctx, + `INSERT INTO facts (ts, kind, key, value, source, confidence) VALUES (0, 'env', ?, 'x', 'poll:caldav', 1.0)`, + key); err != nil { + t.Fatalf("seed %q: %v", key, err) + } + } + if _, err := s.db.ExecContext(ctx, migrations[17]); err != nil { + t.Fatalf("migration 18: %v", err) + } + + var got int + if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM facts WHERE key LIKE 'calendar_event_%'`).Scan(&got); err != nil { + t.Fatal(err) + } + if got != 2 { + t.Fatalf("%d calendar rows left, want the 2 that identify their event", got) + } +}