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,