From 1caf73a3e9f572068d78d442dbcc5d2590505ecc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:44:05 +0200 Subject: [PATCH] fix: never send an occurrence change to the next occurrence A first change to one occurrence that keeps the attendees' answers goes out in two writes: the override as the occurrence stands, quietly, then the change as an update of it. The occurrence is looked up again in between, because the new override can give it another synthetic id. When that lookup failed, the change went to the old id. Up to Stalwart 0.16.20 synthetic ids are positions in the series' expansion (calendar_event/mod.rs), so after the new override the old id can name the next occurrence, which then got the change and its scheduling messages. 0.16.21 and 1.0 key the ids by recurrence id, and the id stays (checked live on 1.0 and 0.16.23). Now, when the occurrence is not found again, found twice, or the lookup fails, nothing more is sent and the editor says the change was not saved. The quiet override changes nothing that shows; the calendar is read again so a retry uses the ids the series has now, and goes out as an update of that override, which reaches every attendee. The message is translated into all 26 languages. --- locales/rn/ar.json | 1 + locales/rn/ca.json | 1 + locales/rn/cs.json | 1 + locales/rn/da.json | 1 + locales/rn/de.json | 1 + locales/rn/en.json | 1 + locales/rn/es.json | 1 + locales/rn/fa.json | 1 + locales/rn/fr.json | 1 + locales/rn/he.json | 1 + locales/rn/hu.json | 1 + locales/rn/it.json | 1 + locales/rn/ja.json | 1 + locales/rn/ko.json | 1 + locales/rn/lv.json | 1 + locales/rn/mn.json | 1 + locales/rn/nb.json | 1 + locales/rn/nl.json | 1 + locales/rn/pl.json | 1 + locales/rn/pt.json | 1 + locales/rn/ro.json | 1 + locales/rn/ru.json | 1 + locales/rn/sk.json | 1 + locales/rn/tr.json | 1 + locales/rn/uk.json | 1 + locales/rn/zh-TW.json | 1 + locales/rn/zh.json | 1 + .../calendar-store-occurrence-answers.test.ts | 52 ++++++++++++++++--- src/stores/calendar-store.ts | 41 +++++++++++++-- 29 files changed, 108 insertions(+), 12 deletions(-) diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 28c4edad..99dea6b7 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, zero {يبدأ بعد # ساعة} one {يبدأ بعد ساعة واحدة} two {يبدأ بعد ساعتين} few {يبدأ بعد # ساعات} many {يبدأ بعد # ساعة} other {يبدأ بعد # ساعة}}", "starts_in_minutes": "{count, plural, zero {يبدأ بعد # دقيقة} one {يبدأ بعد دقيقة واحدة} two {يبدأ بعد دقيقتين} few {يبدأ بعد # دقائق} many {يبدأ بعد # دقيقة} other {يبدأ بعد # دقيقة}}" }, + "occurrence_not_found_again": "لم يُحفظ التغيير: تعذّر العثور على هذا التكرار مرة أخرى على الخادم. حاول مرة أخرى.", "participants": { "limit_warning": "أكثر من {max, plural, zero {# مشارك} one {مشارك واحد} two {مشاركَين} few {# مشاركين} many {# مشاركًا} other {# مشارك}}، بمن فيهم المنظّم: قد يرفض الخادم حفظ هذا الحدث. يمكن للمسؤول رفع هذا الحد." }, diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 17c0ea7f..3d24323b 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Comença d'aquí a # hora} other {Comença d'aquí a # hores}}", "starts_in_minutes": "{count, plural, one {Comença d'aquí a # minut} other {Comença d'aquí a # minuts}}" }, + "occurrence_not_found_again": "No s'ha desat el canvi: no s'ha pogut tornar a trobar la repetició al servidor. Torneu-ho a provar.", "participants": { "limit_warning": "Més de {max, plural, one {# participant} other {# participants}}, inclòs l'organitzador: és possible que el servidor es negui a desar aquest esdeveniment. Un administrador pot augmentar el límit." }, diff --git a/locales/rn/cs.json b/locales/rn/cs.json index fabb1101..236db453 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Začíná za # hodinu} few {Začíná za # hodiny} many {Začíná za # hodiny} other {Začíná za # hodin}}", "starts_in_minutes": "{count, plural, one {Začíná za # minutu} few {Začíná za # minuty} many {Začíná za # minuty} other {Začíná za # minut}}" }, + "occurrence_not_found_again": "Změna nebyla uložena: opakování se na serveru nepodařilo znovu najít. Zkuste to znovu.", "participants": { "limit_warning": "Více než {max, plural, one {# účastník} few {# účastníci} many {# účastníka} other {# účastníků}} včetně organizátora: server může uložení této události odmítnout. Správce může limit zvýšit." }, diff --git a/locales/rn/da.json b/locales/rn/da.json index bd9dae7c..3e2d3245 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Starter om # time} other {Starter om # timer}}", "starts_in_minutes": "{count, plural, one {Starter om # minut} other {Starter om # minutter}}" }, + "occurrence_not_found_again": "Ændringen blev ikke gemt: forekomsten kunne ikke findes igen på serveren. Prøv igen.", "participants": { "limit_warning": "Mere end {max, plural, one {# deltager} other {# deltagere}}, arrangøren medregnet: serveren kan afvise at gemme denne begivenhed. En administrator kan hæve grænsen." }, diff --git a/locales/rn/de.json b/locales/rn/de.json index 3b0b76ac..500d7c1b 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Beginnt in # Stunde} other {Beginnt in # Stunden}}", "starts_in_minutes": "{count, plural, one {Beginnt in # Minute} other {Beginnt in # Minuten}}" }, + "occurrence_not_found_again": "Die Änderung wurde nicht gespeichert: Der Termin konnte auf dem Server nicht wiedergefunden werden. Versuchen Sie es erneut.", "participants": { "limit_warning": "Mehr als {max, plural, one {# Teilnehmer} other {# Teilnehmer}}, den Organisator mitgezählt: Der Server lehnt das Speichern dieses Termins möglicherweise ab. Ihr Administrator kann das Limit erhöhen." }, diff --git a/locales/rn/en.json b/locales/rn/en.json index f300ba16..5780c8cb 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Starts in # hour} other {Starts in # hours}}", "starts_in_minutes": "{count, plural, one {Starts in # minute} other {Starts in # minutes}}" }, + "occurrence_not_found_again": "The change was not saved: the occurrence could not be found again on the server. Please try again.", "participants": { "limit_warning": "More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit." }, diff --git a/locales/rn/es.json b/locales/rn/es.json index d9322700..ec8bdc77 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Comienza en # hora} other {Comienza en # horas}}", "starts_in_minutes": "{count, plural, one {Comienza en # minuto} other {Comienza en # minutos}}" }, + "occurrence_not_found_again": "No se guardó el cambio: no se pudo volver a encontrar la repetición en el servidor. Inténtalo de nuevo.", "participants": { "limit_warning": "Más de {max, plural, one {# participante} other {# participantes}}, incluido el organizador: es posible que el servidor se niegue a guardar este evento. Un administrador puede aumentar el límite." }, diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 6d2daac1..1aad1dd1 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {# ساعت دیگر شروع می‌شود} other {# ساعت دیگر شروع می‌شود}}", "starts_in_minutes": "{count, plural, one {# دقیقه دیگر شروع می‌شود} other {# دقیقه دیگر شروع می‌شود}}" }, + "occurrence_not_found_again": "تغییر ذخیره نشد: این تکرار دوباره روی سرور پیدا نشد. دوباره تلاش کنید.", "participants": { "limit_warning": "بیش از {max, plural, one {# شرکت‌کننده} other {# شرکت‌کننده}}، با احتساب برگزارکننده: ممکن است سرور ذخیره این رویداد را رد کند. مدیر سیستم می‌تواند این محدودیت را افزایش دهد." }, diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 92219edc..c56cb996 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Commence dans # heure} other {Commence dans # heures}}", "starts_in_minutes": "{count, plural, one {Commence dans # minute} other {Commence dans # minutes}}" }, + "occurrence_not_found_again": "La modification n'a pas été enregistrée : l'occurrence n'a pas pu être retrouvée sur le serveur. Réessayez.", "participants": { "limit_warning": "Plus de {max, plural, one {# participant} other {# participants}}, organisateur compris : le serveur risque de refuser d'enregistrer cet événement. Un administrateur peut relever la limite." }, diff --git a/locales/rn/he.json b/locales/rn/he.json index ffaea5ae..2f13e310 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {מתחיל בעוד שעה} two {מתחיל בעוד שעתיים} other {מתחיל בעוד # שעות}}", "starts_in_minutes": "{count, plural, one {מתחיל בעוד דקה} two {מתחיל בעוד שתי דקות} other {מתחיל בעוד # דקות}}" }, + "occurrence_not_found_again": "השינוי לא נשמר: לא ניתן היה למצוא שוב את המופע בשרת. נסה שוב.", "participants": { "limit_warning": "יותר מ-{max, plural, one {# משתתף} other {# משתתפים}}, כולל המארגן: ייתכן שהשרת יסרב לשמור את האירוע. מנהל המערכת יכול להגדיל את המגבלה." }, diff --git a/locales/rn/hu.json b/locales/rn/hu.json index eb6c5424..14f3e78f 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {# óra múlva kezdődik} other {# óra múlva kezdődik}}", "starts_in_minutes": "{count, plural, one {# perc múlva kezdődik} other {# perc múlva kezdődik}}" }, + "occurrence_not_found_again": "A módosítás nem lett mentve: az alkalmat nem sikerült újra megtalálni a szerveren. Próbáld újra.", "participants": { "limit_warning": "Több mint {max, plural, one {# résztvevő} other {# résztvevő}}, a szervezőt is beleértve: a szerver elutasíthatja az esemény mentését. A rendszergazda megemelheti a korlátot." }, diff --git a/locales/rn/it.json b/locales/rn/it.json index ea71283a..fdfe18d1 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Inizia tra # ora} other {Inizia tra # ore}}", "starts_in_minutes": "{count, plural, one {Inizia tra # minuto} other {Inizia tra # minuti}}" }, + "occurrence_not_found_again": "La modifica non è stata salvata: non è stato possibile ritrovare l'occorrenza sul server. Riprova.", "participants": { "limit_warning": "Più di {max, plural, one {# partecipante} other {# partecipanti}}, organizzatore incluso: il server potrebbe rifiutarsi di salvare questo evento. Un amministratore può aumentare il limite." }, diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 4417adb8..3f196e5e 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, other {#時間後に開始}}", "starts_in_minutes": "{count, plural, other {#分後に開始}}" }, + "occurrence_not_found_again": "変更は保存されませんでした:サーバーで対象の回をもう一度見つけられませんでした。もう一度お試しください。", "participants": { "limit_warning": "参加者が主催者を含めて{max, plural, other {#人}}を超えています。サーバーがこの予定の保存を拒否する可能性があります。管理者が上限を引き上げることができます。" }, diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 395ceb3e..36889ce5 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, other {#시간 후 시작}}", "starts_in_minutes": "{count, plural, other {#분 후 시작}}" }, + "occurrence_not_found_again": "변경 사항이 저장되지 않았어요. 서버에서 해당 회차를 다시 찾을 수 없었어요. 다시 시도해 주세요.", "participants": { "limit_warning": "참석자가 주최자를 포함해 {max, plural, other {#명}}을 넘어요. 서버가 이 일정의 저장을 거부할 수 있어요. 관리자가 한도를 늘릴 수 있어요." }, diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 5500acbb..ba42cfa0 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, zero {Sākas pēc # stundām} one {Sākas pēc # stundas} other {Sākas pēc # stundām}}", "starts_in_minutes": "{count, plural, zero {Sākas pēc # minūtēm} one {Sākas pēc # minūtes} other {Sākas pēc # minūtēm}}" }, + "occurrence_not_found_again": "Izmaiņas netika saglabātas: šo notikuma reizi serverī neizdevās atrast vēlreiz. Mēģiniet vēlreiz.", "participants": { "limit_warning": "Vairāk nekā {max, plural, one {# dalībnieks} other {# dalībnieki}}, ieskaitot organizatoru: serveris var atteikties saglabāt šo pasākumu. Administrators var palielināt ierobežojumu." }, diff --git a/locales/rn/mn.json b/locales/rn/mn.json index fbb0a1be..1861448f 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {# цагийн дараа эхэлнэ} other {# цагийн дараа эхэлнэ}}", "starts_in_minutes": "{count, plural, one {# минутын дараа эхэлнэ} other {# минутын дараа эхэлнэ}}" }, + "occurrence_not_found_again": "Өөрчлөлт хадгалагдсангүй: энэ удаагийн үйл явдлыг серверээс дахин олж чадсангүй. Дахин оролдоно уу.", "participants": { "limit_warning": "Зохион байгуулагчийг оролцуулаад {max, plural, other {#}}-аас олон оролцогч: сервер энэ үйл явдлыг хадгалахаас татгалзаж магадгүй. Администратор хязгаарыг нэмэгдүүлэх боломжтой." }, diff --git a/locales/rn/nb.json b/locales/rn/nb.json index b15c2314..537a5920 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Starter om # time} other {Starter om # timer}}", "starts_in_minutes": "{count, plural, one {Starter om # minutt} other {Starter om # minutter}}" }, + "occurrence_not_found_again": "Endringen ble ikke lagret: forekomsten ble ikke funnet igjen på serveren. Prøv igjen.", "participants": { "limit_warning": "Mer enn {max, plural, one {# deltaker} other {# deltakere}}, arrangøren medregnet: serveren kan nekte å lagre denne hendelsen. En administrator kan øke grensen." }, diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 1d1bf74c..a5709895 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Begint over # uur} other {Begint over # uur}}", "starts_in_minutes": "{count, plural, one {Begint over # minuut} other {Begint over # minuten}}" }, + "occurrence_not_found_again": "De wijziging is niet opgeslagen: de herhaling kon niet opnieuw op de server worden gevonden. Probeer het opnieuw.", "participants": { "limit_warning": "Meer dan {max, plural, one {# deelnemer} other {# deelnemers}}, de organisator meegeteld: de server kan weigeren dit evenement op te slaan. Een beheerder kan de limiet verhogen." }, diff --git a/locales/rn/pl.json b/locales/rn/pl.json index e7ee4eb6..95028881 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Zaczyna się za # godzinę} few {Zaczyna się za # godziny} many {Zaczyna się za # godzin} other {Zaczyna się za # godziny}}", "starts_in_minutes": "{count, plural, one {Zaczyna się za # minutę} few {Zaczyna się za # minuty} many {Zaczyna się za # minut} other {Zaczyna się za # minuty}}" }, + "occurrence_not_found_again": "Zmiana nie została zapisana: nie udało się ponownie znaleźć wystąpienia na serwerze. Spróbuj ponownie.", "participants": { "limit_warning": "Ponad {max, plural, one {# uczestnika} few {# uczestników} many {# uczestników} other {# uczestnika}} łącznie z organizatorem: serwer może odmówić zapisania tego wydarzenia. Administrator może zwiększyć limit." }, diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 31461840..aeb505b0 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Começa em # hora} other {Começa em # horas}}", "starts_in_minutes": "{count, plural, one {Começa em # minuto} other {Começa em # minutos}}" }, + "occurrence_not_found_again": "A alteração não foi salva: não foi possível encontrar a repetição novamente no servidor. Tente novamente.", "participants": { "limit_warning": "Mais de {max, plural, one {# participante} other {# participantes}}, incluindo o organizador: o servidor pode se recusar a salvar este evento. Um administrador pode aumentar o limite." }, diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 0442289c..7ed75778 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Începe peste # oră} few {Începe peste # ore} other {Începe peste # de ore}}", "starts_in_minutes": "{count, plural, one {Începe peste # minut} few {Începe peste # minute} other {Începe peste # de minute}}" }, + "occurrence_not_found_again": "Modificarea nu a fost salvată: ocurența nu a mai putut fi găsită pe server. Încercați din nou.", "participants": { "limit_warning": "Mai mult de {max, plural, one {# participant} few {# participanți} other {# de participanți}}, inclusiv organizatorul: serverul poate refuza salvarea acestui eveniment. Un administrator poate mări limita." }, diff --git a/locales/rn/ru.json b/locales/rn/ru.json index d2e4a783..6c282026 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Начнётся через # час} few {Начнётся через # часа} many {Начнётся через # часов} other {Начнётся через # часа}}", "starts_in_minutes": "{count, plural, one {Начнётся через # минуту} few {Начнётся через # минуты} many {Начнётся через # минут} other {Начнётся через # минуты}}" }, + "occurrence_not_found_again": "Изменение не сохранено: не удалось снова найти это повторение на сервере. Попробуйте ещё раз.", "participants": { "limit_warning": "Больше {max, plural, one {# участника} few {# участников} many {# участников} other {# участника}}, включая организатора: сервер может отказаться сохранить это событие. Администратор может увеличить лимит." }, diff --git a/locales/rn/sk.json b/locales/rn/sk.json index f2cf7c93..f8041e38 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Začína o # hodinu} few {Začína o # hodiny} many {Začína o # hodiny} other {Začína o # hodín}}", "starts_in_minutes": "{count, plural, one {Začína o # minútu} few {Začína o # minúty} many {Začína o # minúty} other {Začína o # minút}}" }, + "occurrence_not_found_again": "Zmena sa neuložila: opakovanie sa na serveri nepodarilo znova nájsť. Skúste to znova.", "participants": { "limit_warning": "Viac ako {max, plural, one {# účastník} few {# účastníci} many {# účastníka} other {# účastníkov}} vrátane organizátora: server môže odmietnuť uloženie tejto udalosti. Správca môže limit zvýšiť." }, diff --git a/locales/rn/tr.json b/locales/rn/tr.json index dd9e86b4..f642523b 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {# saat sonra başlıyor} other {# saat sonra başlıyor}}", "starts_in_minutes": "{count, plural, one {# dakika sonra başlıyor} other {# dakika sonra başlıyor}}" }, + "occurrence_not_found_again": "Değişiklik kaydedilmedi: bu tekrar sunucuda yeniden bulunamadı. Tekrar deneyin.", "participants": { "limit_warning": "Organizatör dahil {max, plural, one {# katılımcıdan} other {# katılımcıdan}} fazla: sunucu bu etkinliği kaydetmeyi reddedebilir. Bir yönetici bu sınırı yükseltebilir." }, diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 5e57c30b..db20f423 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, one {Почнеться через # годину} few {Почнеться через # години} many {Почнеться через # годин} other {Почнеться через # години}}", "starts_in_minutes": "{count, plural, one {Почнеться через # хвилину} few {Почнеться через # хвилини} many {Почнеться через # хвилин} other {Почнеться через # хвилини}}" }, + "occurrence_not_found_again": "Зміну не збережено: не вдалося знову знайти це повторення на сервері. Спробуйте ще раз.", "participants": { "limit_warning": "Понад {max, plural, one {# учасника} few {# учасників} many {# учасників} other {# учасника}}, включно з організатором: сервер може відмовитися зберегти цю подію. Адміністратор може збільшити ліміт." }, diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index b925d494..ad3ec004 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, other {# 小時後開始}}", "starts_in_minutes": "{count, plural, other {# 分鐘後開始}}" }, + "occurrence_not_found_again": "變更未儲存:無法在伺服器上再次找到這一次的活動。請再試一次。", "participants": { "limit_warning": "參與者(含主辦人)超過 {max, plural, other {# 位}}:伺服器可能會拒絕儲存此活動。管理員可以提高此限制。" }, diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 1956245e..4df00eaa 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -84,6 +84,7 @@ "starts_in_hours": "{count, plural, other {# 小时后开始}}", "starts_in_minutes": "{count, plural, other {# 分钟后开始}}" }, + "occurrence_not_found_again": "更改未保存:无法在服务器上再次找到这一次的活动。请重试。", "participants": { "limit_warning": "参与者(含组织者)超过 {max, plural, other {# 位}}:服务器可能会拒绝保存此活动。管理员可以提高此限制。" }, diff --git a/src/stores/__tests__/calendar-store-occurrence-answers.test.ts b/src/stores/__tests__/calendar-store-occurrence-answers.test.ts index a5f0c40a..529fc654 100644 --- a/src/stores/__tests__/calendar-store-occurrence-answers.test.ts +++ b/src/stores/__tests__/calendar-store-occurrence-answers.test.ts @@ -92,7 +92,8 @@ beforeEach(() => { describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on Stalwart %s', () => { it('writes the override as it stands, then sends the change to its re-read id', async () => { useCalendarStore.setState({ events: [serverOccurrence()] }); - // The new override gives the occurrence another synthetic id. + // The new override gives the occurrence another synthetic id (positional + // ids, up to 0.16.20; 0.16.21 and 1.0 keep it, see below). mockQueryExpanded.mockResolvedValue(['s9', 'other']); mockGetEvents.mockImplementation(async (ids: string[]) => (ids.includes('s9') ? [ @@ -113,6 +114,18 @@ describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on St expect(mockGetEvents).toHaveBeenCalledWith(['s9', 'other'], undefined, { expanded: true }); }); + it('sends the change to the same id when the override keeps it (recurrence-keyed ids, live)', async () => { + useCalendarStore.setState({ events: [serverOccurrence()] }); + mockQueryExpanded.mockResolvedValue(['s1']); + mockGetEvents.mockResolvedValue([{ id: 's1', baseEventId: 'base', recurrenceId: RID, start: RID, duration: 'PT1H' } as CalendarEvent]); + + await useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial, { + sendSchedulingMessages: true, + }); + + expect(mockUpdate.mock.calls.map(([id, , schedule]) => [id, schedule])).toEqual([['s1', false], ['s1', true]]); + }); + it('sends the change once when it asks the attendees again (a move)', async () => { useCalendarStore.setState({ events: [serverOccurrence()] }); @@ -142,15 +155,40 @@ describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on St expect(mockUpdate.mock.calls.map(([id, , schedule]) => [id, schedule])).toEqual([['s1', false]]); }); - it('sends the change to the old id when the occurrence cannot be found again', async () => { + // Up to 0.16.20 Stalwart's synthetic ids are positions in the series' + // expansion (calendar_event/mod.rs): after the new override "s1" can name + // the next occurrence, which must not get the change and its scheduling + // messages. 0.16.21 and 1.0 keep the id; the lookup finds it again there. + it.each([ + ['is not listed', () => mockQueryExpanded.mockResolvedValue(null)], + ['cannot be read', () => mockQueryExpanded.mockRejectedValue(new Error('Network request failed'))], + ['is listed twice', () => { + mockQueryExpanded.mockResolvedValue(['s9', 's10']); + mockGetEvents.mockResolvedValue([ + { id: 's9', baseEventId: 'base', recurrenceId: RID, start: RID } as CalendarEvent, + { id: 's10', baseEventId: 'base', recurrenceId: RID, start: RID } as CalendarEvent, + ]); + }], + ])('sends nothing more, and says so, when the occurrence %s after its quiet override', async (_what, arrange) => { useCalendarStore.setState({ events: [serverOccurrence()] }); - mockQueryExpanded.mockResolvedValue(null); + arrange(); + const refresh = vi.fn(async () => undefined); + const { refresh: storeRefresh } = useCalendarStore.getState(); + useCalendarStore.setState({ refresh }); - await useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial, { - sendSchedulingMessages: true, - }); + try { + await expect(useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial, { + sendSchedulingMessages: true, + })).rejects.toThrow('The change was not saved: the occurrence could not be found again on the server. Please try again.'); + } finally { + useCalendarStore.setState({ refresh: storeRefresh }); + } - expect(mockUpdate.mock.calls.map(([id, , schedule]) => [id, schedule])).toEqual([['s1', false], ['s1', true]]); + // Only the override as the occurrence stands, which reaches no one. + expect(mockUpdate.mock.calls.map(([id, , schedule]) => [id, schedule])).toEqual([['s1', false]]); + // The ids in view may name other occurrences now: read them again. + expect(refresh).toHaveBeenCalled(); + expect(useCalendarStore.getState().events[0].title).toBe('Standup'); }); }); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index c1c88ad0..515b6074 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -519,7 +519,8 @@ async function updateOccurrence( /** * The server occurrence of `instance`'s series at the same recurrence id, read - * again: a new override can give an occurrence another synthetic id. + * again: a new override can give an occurrence another synthetic id. Null + * when the series lists none there, or more than one. */ async function findServerOccurrence( instance: CalendarEvent, @@ -531,7 +532,24 @@ async function findServerOccurrence( const ids = await queryExpandedEvents(new Date(at - DAY_MS).toISOString(), new Date(at + DAY_MS).toISOString(), accountId); if (!ids?.length) return null; const found = await fetchEvents(ids, accountId, { expanded: true }); - return found.find((e) => e.baseEventId === instance.baseEventId && e.recurrenceId === instance.recurrenceId) ?? null; + const matches = found.filter((e) => e.baseEventId === instance.baseEventId && e.recurrenceId === instance.recurrenceId); + return matches.length === 1 ? matches[0] : null; +} + +/** + * A first change to one occurrence could not be sent: after its quiet + * override was written, the occurrence was not found again (see + * updateOccurrenceInTwoSteps). That override changes nothing that shows, and + * a retry updates it, which reaches every attendee. + */ +export class OccurrenceNotFoundAgainError extends Error { + constructor() { + super(t( + 'calendar.occurrence_not_found_again', + 'The change was not saved: the occurrence could not be found again on the server. Please try again.', + )); + this.name = 'OccurrenceNotFoundAgainError'; + } } /** @@ -541,7 +559,12 @@ async function findServerOccurrence( * change; an update of an existing override reaches everyone. So the override * is written first as the occurrence stands, without scheduling, and the * change then goes out as an update of it. The occurrence is looked up again - * in between: the new override can give it another synthetic id. + * in between: the new override can give it another synthetic id. When that + * fails, the change is not sent to the old id: up to 0.16.20 Stalwart's + * synthetic ids are positions in the series' expansion (calendar_event/ + * mod.rs), and after the new override the old one can name the next + * occurrence, which would get the change and its scheduling messages. + * 0.16.21 and 1.0 key them by recurrence id, and the id stays (live). */ async function updateOccurrenceInTwoSteps( instance: CalendarEvent, @@ -552,7 +575,8 @@ async function updateOccurrenceInTwoSteps( ): Promise { await updateOccurrence(instance, syntheticId, (options) => withNewOverrideDetails(instance, {}, options), false, accountId); const current = await findServerOccurrence(instance, accountId).catch(() => null); - await updateOccurrence(current ?? instance, current?.id ?? syntheticId, () => patch, sendSchedulingMessages, accountId); + if (!current) throw new OccurrenceNotFoundAgainError(); + await updateOccurrence(current, current.id, () => patch, sendSchedulingMessages, accountId); } /** @@ -945,7 +969,14 @@ export const useCalendarStore = create()( await stopSeriesDefaultAlerts(storeEvent.baseEventId, accountId); } if (twoSteps) { - await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial, schedule, accountId); + try { + await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial, schedule, accountId); + } catch (err) { + // Its quiet override is on the server, and the series' occurrences + // may have other ids now: a retry must not go to the ones in view. + if (err instanceof OccurrenceNotFoundAgainError) await get().refresh().catch(() => undefined); + throw err; + } } else { await updateOccurrence( storeEvent,