fix: keep attendees' answers on save and deliver occurrence changes

Every save in the event editor rebuilt the participants with new ids
and put every attendee back to needs-action, the user's own answer on
an invitation included. Saves now keep each attendee's id and answer;
new attendees are asked. As on the web, only an organizer who moves an
event (its start, end, all-day or recurrence rule; not a time zone
rewrite, and not a zero-length event) asks the others again.

The series scopes did not fit that:

- "All events" wrote the opened occurrence's start, length, zone and
  participants onto the series: a stretched occurrence stretched every
  one, and one day's decline spread to the whole series. The series now
  keeps its own list and takes only the attendees the user added or
  removed, and its times change only when the occurrence moved (by the
  same delta), its rule only when it changed.
- "This and following" makes a new series, which Stalwart sends only to
  attendees who have not answered: its attendees are asked again.
- Stalwart sends a new occurrence override, the first change to one
  occurrence, only to attendees who have not answered (0.16.6 to 1.0).
  A change that keeps answers now goes out in two writes: the override
  as the occurrence stands without scheduling, then the change as an
  update of it, to the occurrence looked up again. Checked live on 1.0
  and 0.16.23.
- A save of an invitation someone else organizes writes no participants
  while the user left them alone, and never asks for scheduling, so the
  organizer gets no reply for a changed alert. The switch to send
  invitations is hidden there.

Also:

- a reminder change on one occurrence of a series that uses the
  calendar's default alerts turns the defaults off for the series first
  (keeping what it shows under ids of its own); 1.0 kept showing the
  defaults
- all-day and floating occurrences expanded on the device no longer
  carry the master's utcStart/utcEnd, so their reminders are scheduled
  for each day; master instants that are not the master's own (0.16.23's
  year 32548, or the next occurrence's) are ignored
- the editor warns when the attendees and the organizer go over the
  calendars capability's maxParticipantsPerEvent, and a refusal for it
  (Stalwart 1.0 enforces it, answers included) is explained in the
  editor, the series dialogs, the detail sheet and the invitation
  banner, in all languages; a failed save used to fail silently
- refused event creates, updates and answers keep their SetError type
- Calendar/get asks for its properties by name
This commit is contained in:
Linus Rath
2026-09-25 23:50:29 +02:00
parent f490886a8b
commit a562ea59b8
45 changed files with 1474 additions and 78 deletions
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "تذكيرات التقويم",
"participant_limit": "يسمح الخادم بحد أقصى {max, plural, zero {# مشارك} one {مشارك واحد} two {مشاركَين} few {# مشاركين} many {# مشاركًا} other {# مشارك}} لكل حدث، بمن فيهم المنظّم، لذا رفض هذا التغيير. يمكن للمسؤول رفع هذا الحد.",
"starts_in_days": "{count, plural, zero {يبدأ بعد # يوم} one {يبدأ بعد يوم واحد} two {يبدأ بعد يومين} few {يبدأ بعد # أيام} many {يبدأ بعد # يومًا} other {يبدأ بعد # يوم}}",
"starts_in_hours": "{count, plural, zero {يبدأ بعد # ساعة} one {يبدأ بعد ساعة واحدة} two {يبدأ بعد ساعتين} few {يبدأ بعد # ساعات} many {يبدأ بعد # ساعة} other {يبدأ بعد # ساعة}}",
"starts_in_minutes": "{count, plural, zero {يبدأ بعد # دقيقة} one {يبدأ بعد دقيقة واحدة} two {يبدأ بعد دقيقتين} few {يبدأ بعد # دقائق} many {يبدأ بعد # دقيقة} other {يبدأ بعد # دقيقة}}"
},
"participants": {
"limit_warning": "أكثر من {max, plural, zero {# مشارك} one {مشارك واحد} two {مشاركَين} few {# مشاركين} many {# مشاركًا} other {# مشارك}}، بمن فيهم المنظّم: قد يرفض الخادم حفظ هذا الحدث. يمكن للمسؤول رفع هذا الحد."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, zero {مرة} one {مرة} two {مرات} few {مرات} many {مرة} other {مرة}}",
"every_count_days": "{count, plural, zero {كل # يوم} one {كل يوم} two {كل يومين} few {كل # أيام} many {كل # يومًا} other {كل # يوم}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Recordatoris del calendari",
"participant_limit": "El servidor permet com a màxim {max, plural, one {# participant} other {# participants}} per esdeveniment, inclòs l'organitzador, per això ha rebutjat aquest canvi. Un administrador pot augmentar el límit.",
"starts_in_days": "{count, plural, one {Comença d'aquí a # dia} other {Comença d'aquí a # dies}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {repetició} other {repeticions}}",
"every_count_days": "{count, plural, one {Cada dia} other {Cada # dies}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Připomenutí kalendáře",
"participant_limit": "Server povoluje nejvýše {max, plural, one {# účastníka} few {# účastníky} many {# účastníka} other {# účastníků}} na událost včetně organizátora, proto tuto změnu odmítl. Správce může limit zvýšit.",
"starts_in_days": "{count, plural, one {Začíná za # den} few {Začíná za # dny} many {Začíná za # dne} other {Začíná za # dní}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {opakování} few {opakováních} many {opakování} other {opakováních}}",
"every_count_days": "{count, plural, one {Každý den} few {Každé # dny} many {Každých # dne} other {Každých # dní}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Kalenderpåmindelser",
"participant_limit": "Serveren tillader højst {max, plural, one {# deltager} other {# deltagere}} pr. begivenhed, arrangøren medregnet, så ændringen blev afvist. En administrator kan hæve grænsen.",
"starts_in_days": "{count, plural, one {Starter om # dag} other {Starter om # dage}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {gentagelse} other {gentagelser}}",
"every_count_days": "{count, plural, one {Hver dag} other {Hver #. dag}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Kalendererinnerungen",
"participant_limit": "Der Server erlaubt höchstens {max, plural, one {# Teilnehmer} other {# Teilnehmer}} pro Termin, den Organisator mitgezählt, und hat die Änderung deshalb abgelehnt. Ihr Administrator kann das Limit erhöhen.",
"starts_in_days": "{count, plural, one {Beginnt in # Tag} other {Beginnt in # Tagen}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {Termin} other {Terminen}}",
"every_count_days": "{count, plural, one {Jeden Tag} other {Alle # Tage}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Calendar reminders",
"participant_limit": "The server allows at most {max, plural, one {# participant} other {# participants}} per event, including the organizer, so it refused this change. An administrator can raise the limit.",
"starts_in_days": "{count, plural, one {Starts in # day} other {Starts in # days}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {occurrence} other {occurrences}}",
"every_count_days": "{count, plural, one {Every # day} other {Every # days}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Recordatorios del calendario",
"participant_limit": "El servidor permite como máximo {max, plural, one {# participante} other {# participantes}} por evento, incluido el organizador, por lo que rechazó este cambio. Un administrador puede aumentar el límite.",
"starts_in_days": "{count, plural, one {Comienza en # día} other {Comienza en # días}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {repetición} other {repeticiones}}",
"every_count_days": "{count, plural, one {Cada día} other {Cada # días}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "یادآورهای تقویم",
"participant_limit": "سرور برای هر رویداد، با احتساب برگزارکننده، حداکثر {max, plural, one {# شرکت‌کننده} other {# شرکت‌کننده}} را می‌پذیرد، به همین دلیل این تغییر را رد کرد. مدیر سیستم می‌تواند این محدودیت را افزایش دهد.",
"starts_in_days": "{count, plural, one {# روز دیگر شروع می‌شود} other {# روز دیگر شروع می‌شود}}",
"starts_in_hours": "{count, plural, one {# ساعت دیگر شروع می‌شود} other {# ساعت دیگر شروع می‌شود}}",
"starts_in_minutes": "{count, plural, one {# دقیقه دیگر شروع می‌شود} other {# دقیقه دیگر شروع می‌شود}}"
},
"participants": {
"limit_warning": "بیش از {max, plural, one {# شرکت‌کننده} other {# شرکت‌کننده}}، با احتساب برگزارکننده: ممکن است سرور ذخیره این رویداد را رد کند. مدیر سیستم می‌تواند این محدودیت را افزایش دهد."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {بار} other {بار}}",
"every_count_days": "{count, plural, one {هر # روز} other {هر # روز}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Rappels du calendrier",
"participant_limit": "Le serveur autorise au plus {max, plural, one {# participant} other {# participants}} par événement, organisateur compris, il a donc refusé cette modification. Un administrateur peut relever la limite.",
"starts_in_days": "{count, plural, one {Commence dans # jour} other {Commence dans # jours}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {occurrence} other {occurrences}}",
"every_count_days": "{count, plural, one {Tous les jours} other {Tous les # jours}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "תזכורות לוח שנה",
"participant_limit": "השרת מאפשר עד {max, plural, one {# משתתף} other {# משתתפים}} לאירוע, כולל המארגן, ולכן דחה את השינוי. מנהל המערכת יכול להגדיל את המגבלה.",
"starts_in_days": "{count, plural, one {מתחיל בעוד יום} two {מתחיל בעוד יומיים} other {מתחיל בעוד # ימים}}",
"starts_in_hours": "{count, plural, one {מתחיל בעוד שעה} two {מתחיל בעוד שעתיים} other {מתחיל בעוד # שעות}}",
"starts_in_minutes": "{count, plural, one {מתחיל בעוד דקה} two {מתחיל בעוד שתי דקות} other {מתחיל בעוד # דקות}}"
},
"participants": {
"limit_warning": "יותר מ-{max, plural, one {# משתתף} other {# משתתפים}}, כולל המארגן: ייתכן שהשרת יסרב לשמור את האירוע. מנהל המערכת יכול להגדיל את המגבלה."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {מופע} two {מופעים} other {מופעים}}",
"every_count_days": "{count, plural, one {כל יום} two {כל יומיים} other {כל # ימים}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Naptáremlékeztetők",
"participant_limit": "A szerver eseményenként legfeljebb {max, plural, one {# résztvevőt} other {# résztvevőt}} enged, a szervezőt is beleértve, ezért elutasította a módosítást. A rendszergazda megemelheti a korlátot.",
"starts_in_days": "{count, plural, one {# nap múlva kezdődik} other {# nap múlva kezdődik}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {alkalom} other {alkalom}}",
"every_count_days": "{count, plural, one {Naponta} other {# naponta}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Promemoria del calendario",
"participant_limit": "Il server consente al massimo {max, plural, one {# partecipante} other {# partecipanti}} per evento, organizzatore incluso, quindi ha rifiutato la modifica. Un amministratore può aumentare il limite.",
"starts_in_days": "{count, plural, one {Inizia tra # giorno} other {Inizia tra # giorni}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {occorrenza} other {occorrenze}}",
"every_count_days": "{count, plural, one {Ogni giorno} other {Ogni # giorni}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "カレンダーのリマインダー",
"participant_limit": "サーバーでは1件の予定につき参加者は主催者を含めて{max, plural, other {#人}}までのため、この変更は拒否されました。管理者が上限を引き上げることができます。",
"starts_in_days": "{count, plural, other {#日後に開始}}",
"starts_in_hours": "{count, plural, other {#時間後に開始}}",
"starts_in_minutes": "{count, plural, other {#分後に開始}}"
},
"participants": {
"limit_warning": "参加者が主催者を含めて{max, plural, other {#人}}を超えています。サーバーがこの予定の保存を拒否する可能性があります。管理者が上限を引き上げることができます。"
},
"recurrence": {
"editor_occurrences_count": "{count, plural, other {回}}",
"every_count_days": "{count, plural, other {#日ごと}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "캘린더 알림",
"participant_limit": "서버에서 일정당 참석자를 주최자를 포함해 최대 {max, plural, other {#명}}까지만 허용해서 이 변경을 거부했어요. 관리자가 한도를 늘릴 수 있어요.",
"starts_in_days": "{count, plural, other {#일 후 시작}}",
"starts_in_hours": "{count, plural, other {#시간 후 시작}}",
"starts_in_minutes": "{count, plural, other {#분 후 시작}}"
},
"participants": {
"limit_warning": "참석자가 주최자를 포함해 {max, plural, other {#명}}을 넘어요. 서버가 이 일정의 저장을 거부할 수 있어요. 관리자가 한도를 늘릴 수 있어요."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, other {회}}",
"every_count_days": "{count, plural, other {#일마다}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Kalendāra atgādinājumi",
"participant_limit": "Serveris atļauj ne vairāk kā {max, plural, one {# dalībnieku} other {# dalībniekus}} vienam pasākumam, ieskaitot organizatoru, tāpēc šīs izmaiņas tika noraidītas. Administrators var palielināt ierobežojumu.",
"starts_in_days": "{count, plural, zero {Sākas pēc # dienām} one {Sākas pēc # dienas} other {Sākas pēc # dienām}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, zero {reizēm} one {reizes} other {reizēm}}",
"every_count_days": "{count, plural, zero {Ik pēc # dienām} one {Ik pēc # dienas} other {Ik pēc # dienām}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Хуанлийн сануулга",
"participant_limit": "Сервер нэг үйл явдалд зохион байгуулагчийг оролцуулаад хамгийн ихдээ {max, plural, other {#}} оролцогч зөвшөөрдөг тул энэ өөрчлөлтийг татгалзлаа. Администратор хязгаарыг нэмэгдүүлэх боломжтой.",
"starts_in_days": "{count, plural, one {# өдрийн дараа эхэлнэ} other {# өдрийн дараа эхэлнэ}}",
"starts_in_hours": "{count, plural, one {# цагийн дараа эхэлнэ} other {# цагийн дараа эхэлнэ}}",
"starts_in_minutes": "{count, plural, one {# минутын дараа эхэлнэ} other {# минутын дараа эхэлнэ}}"
},
"participants": {
"limit_warning": "Зохион байгуулагчийг оролцуулаад {max, plural, other {#}}-аас олон оролцогч: сервер энэ үйл явдлыг хадгалахаас татгалзаж магадгүй. Администратор хязгаарыг нэмэгдүүлэх боломжтой."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {удаа} other {удаа}}",
"every_count_days": "{count, plural, one {# өдөр бүр} other {# өдөр бүр}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Kalenderpåminnelser",
"participant_limit": "Serveren tillater høyst {max, plural, one {# deltaker} other {# deltakere}} per hendelse, arrangøren medregnet, så endringen ble avvist. En administrator kan øke grensen.",
"starts_in_days": "{count, plural, one {Starter om # dag} other {Starter om # dager}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {forekomst} other {forekomster}}",
"every_count_days": "{count, plural, one {Hver dag} other {Hver #. dag}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Agendaherinneringen",
"participant_limit": "De server staat maximaal {max, plural, one {# deelnemer} other {# deelnemers}} per evenement toe, de organisator meegeteld, en heeft deze wijziging daarom geweigerd. Een beheerder kan de limiet verhogen.",
"starts_in_days": "{count, plural, one {Begint over # dag} other {Begint over # dagen}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {herhaling} other {herhalingen}}",
"every_count_days": "{count, plural, one {Elke dag} other {Elke # dagen}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Przypomnienia kalendarza",
"participant_limit": "Serwer pozwala na maksymalnie {max, plural, one {# uczestnika} few {# uczestników} many {# uczestników} other {# uczestnika}} wydarzenia łącznie z organizatorem, więc odrzucił tę zmianę. Administrator może zwiększyć limit.",
"starts_in_days": "{count, plural, one {Zaczyna się za # dzień} few {Zaczyna się za # dni} many {Zaczyna się za # dni} other {Zaczyna się za # dnia}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {wystąpieniu} few {wystąpieniach} many {wystąpieniach} other {wystąpienia}}",
"every_count_days": "{count, plural, one {Codziennie} few {Co # dni} many {Co # dni} other {Co # dnia}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Lembretes do calendário",
"participant_limit": "O servidor permite no máximo {max, plural, one {# participante} other {# participantes}} por evento, incluindo o organizador, por isso recusou esta alteração. Um administrador pode aumentar o limite.",
"starts_in_days": "{count, plural, one {Começa em # dia} other {Começa em # dias}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {repetição} other {repetições}}",
"every_count_days": "{count, plural, one {Todos os dias} other {A cada # dias}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Mementouri din calendar",
"participant_limit": "Serverul permite cel mult {max, plural, one {# participant} few {# participanți} other {# de participanți}} per eveniment, inclusiv organizatorul, așa că a respins această modificare. Un administrator poate mări limita.",
"starts_in_days": "{count, plural, one {Începe peste # zi} few {Începe peste # zile} other {Începe peste # de zile}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {ocurență} few {ocurențe} other {de ocurențe}}",
"every_count_days": "{count, plural, one {În fiecare zi} few {La fiecare # zile} other {La fiecare # de zile}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Напоминания календаря",
"participant_limit": "Сервер допускает не более {max, plural, one {# участника} few {# участников} many {# участников} other {# участника}} в событии, включая организатора, поэтому отклонил это изменение. Администратор может увеличить лимит.",
"starts_in_days": "{count, plural, one {Начнётся через # день} few {Начнётся через # дня} many {Начнётся через # дней} other {Начнётся через # дня}}",
"starts_in_hours": "{count, plural, one {Начнётся через # час} few {Начнётся через # часа} many {Начнётся через # часов} other {Начнётся через # часа}}",
"starts_in_minutes": "{count, plural, one {Начнётся через # минуту} few {Начнётся через # минуты} many {Начнётся через # минут} other {Начнётся через # минуты}}"
},
"participants": {
"limit_warning": "Больше {max, plural, one {# участника} few {# участников} many {# участников} other {# участника}}, включая организатора: сервер может отказаться сохранить это событие. Администратор может увеличить лимит."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {повторение} few {повторения} many {повторений} other {повторения}}",
"every_count_days": "{count, plural, one {Каждый # день} few {Каждые # дня} many {Каждые # дней} other {Каждые # дня}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Pripomenutia kalendára",
"participant_limit": "Server povoľuje najviac {max, plural, one {# účastníka} few {# účastníkov} many {# účastníka} other {# účastníkov}} na udalosť vrátane organizátora, preto túto zmenu odmietol. Správca môže limit zvýšiť.",
"starts_in_days": "{count, plural, one {Začína o # deň} few {Začína o # dni} many {Začína o # dňa} other {Začína o # dní}}",
"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}}"
},
"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ť."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {opakovaní} few {opakovaniach} many {opakovania} other {opakovaniach}}",
"every_count_days": "{count, plural, one {Každý deň} few {Každé # dni} many {Každých # dňa} other {Každých # dní}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Takvim hatırlatıcıları",
"participant_limit": "Sunucu etkinlik başına organizatör dahil en fazla {max, plural, one {# katılımcıya} other {# katılımcıya}} izin verdiği için bu değişikliği reddetti. Bir yönetici bu sınırı yükseltebilir.",
"starts_in_days": "{count, plural, one {# gün sonra başlıyor} other {# gün sonra başlıyor}}",
"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}}"
},
"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."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {tekrar} other {tekrar}}",
"every_count_days": "{count, plural, one {Her gün} other {Her # günde bir}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "Нагадування календаря",
"participant_limit": "Сервер дозволяє щонайбільше {max, plural, one {# учасника} few {# учасників} many {# учасників} other {# учасника}} у події, включно з організатором, тому відхилив цю зміну. Адміністратор може збільшити ліміт.",
"starts_in_days": "{count, plural, one {Почнеться через # день} few {Почнеться через # дні} many {Почнеться через # днів} other {Почнеться через # дня}}",
"starts_in_hours": "{count, plural, one {Почнеться через # годину} few {Почнеться через # години} many {Почнеться через # годин} other {Почнеться через # години}}",
"starts_in_minutes": "{count, plural, one {Почнеться через # хвилину} few {Почнеться через # хвилини} many {Почнеться через # хвилин} other {Почнеться через # хвилини}}"
},
"participants": {
"limit_warning": "Понад {max, plural, one {# учасника} few {# учасників} many {# учасників} other {# учасника}}, включно з організатором: сервер може відмовитися зберегти цю подію. Адміністратор може збільшити ліміт."
},
"recurrence": {
"editor_occurrences_count": "{count, plural, one {повторення} few {повторення} many {повторень} other {повторення}}",
"every_count_days": "{count, plural, one {Кожен # день} few {Кожні # дні} many {Кожні # днів} other {Кожні # дня}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "行事曆提醒",
"participant_limit": "伺服器每個活動最多允許 {max, plural, other {# 位}}參與者(含主辦人),因此拒絕了這項變更。管理員可以提高此限制。",
"starts_in_days": "{count, plural, other {# 天後開始}}",
"starts_in_hours": "{count, plural, other {# 小時後開始}}",
"starts_in_minutes": "{count, plural, other {# 分鐘後開始}}"
},
"participants": {
"limit_warning": "參與者(含主辦人)超過 {max, plural, other {# 位}}:伺服器可能會拒絕儲存此活動。管理員可以提高此限制。"
},
"recurrence": {
"editor_occurrences_count": "{count, plural, other {次後}}",
"every_count_days": "{count, plural, other {每 # 天}}",
+4
View File
@@ -79,10 +79,14 @@
},
"notifications": {
"channel_name": "日历提醒",
"participant_limit": "服务器每个活动最多允许 {max, plural, other {# 位}}参与者(含组织者),因此拒绝了此更改。管理员可以提高此限制。",
"starts_in_days": "{count, plural, other {# 天后开始}}",
"starts_in_hours": "{count, plural, other {# 小时后开始}}",
"starts_in_minutes": "{count, plural, other {# 分钟后开始}}"
},
"participants": {
"limit_warning": "参与者(含组织者)超过 {max, plural, other {# 位}}:服务器可能会拒绝保存此活动。管理员可以提高此限制。"
},
"recurrence": {
"editor_occurrences_count": "{count, plural, other {次}}",
"every_count_days": "{count, plural, other {每 # 天}}",
+69 -1
View File
@@ -26,7 +26,10 @@ import {
resetTimeZoneChecks,
queryExpandedEvents,
hydrateExpandedOccurrences,
maxParticipantsPerEvent,
rsvpEvent,
} from '../calendar';
import { JMAPSetError, jmapErrorType } from '../jmap-result';
import { useSettingsStore } from '../../stores/settings-store';
describe('toLocalDateTime', () => {
@@ -75,7 +78,14 @@ describe('calendar operations', () => {
expect(result).toEqual(calendars);
expect(mockRequest).toHaveBeenCalledWith(
[
['Calendar/get', { accountId: 'acc-1' }, '0'],
// The properties by name: Stalwart 1.0's unfinished Metadata ones stay out.
['Calendar/get', {
accountId: 'acc-1',
properties: [
'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault',
'myRights', 'shareWith', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime',
],
}, '0'],
// The synthetic-id probe rides along with the first Calendar/get.
['CalendarEvent/set', { accountId: 'acc-1', update: { h333333: {} } }, 'synthetic-id-probe'],
],
@@ -454,6 +464,64 @@ describe('calendar operations', () => {
});
});
describe('refusals for the participant limit (Stalwart 1.0)', () => {
// 1.0 enforces maxParticipantsPerEvent on every create and update, an
// answer included; 0.16 advertises the limit without enforcing it.
const refusal = {
type: 'invalidProperties',
description: 'An event cannot have more than 20 participants.',
properties: ['participants'],
};
it('keeps the SetError type of a refused create', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { notCreated: { 'new-event': refusal } }, '0']],
});
const error = await createEvent({ title: 'All hands' }, 'cal-1').catch((e) => e);
expect(error).toBeInstanceOf(JMAPSetError);
expect(jmapErrorType(error)).toBe('invalidProperties');
expect(error.message).toBe('An event cannot have more than 20 participants.');
expect(error.properties).toEqual(['participants']);
});
it('keeps the SetError type of a refused update and answer', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { notUpdated: { ev1: refusal } }, '0']],
});
await expect(updateEvent('ev1', { title: 'All hands' })).rejects.toBeInstanceOf(JMAPSetError);
const error = await rsvpEvent('ev1', 'me', 'accepted').catch((e) => e);
expect(error).toBeInstanceOf(JMAPSetError);
expect(error.message).toBe('An event cannot have more than 20 participants.');
});
});
describe('maxParticipantsPerEvent', () => {
const session = (calendars: Record<string, unknown>) => ({
accounts: {
'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': calendars } },
shared: { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
},
});
const client = jmapClient as unknown as { isConnected?: boolean; currentSession?: unknown };
it.each(['0.16.23', '1.0'])('reads the calendars capability of the account, else the primary one (Stalwart %s)', () => {
client.isConnected = true;
client.currentSession = session({ maxParticipantsPerEvent: 20 });
try {
expect(maxParticipantsPerEvent()).toBe(20);
expect(maxParticipantsPerEvent('shared')).toBe(20);
client.currentSession = session({});
expect(maxParticipantsPerEvent()).toBeNull();
} finally {
delete client.isConnected;
delete client.currentSession;
}
});
});
describe('updateEvent', () => {
it('should update event fields', async () => {
mockRequest.mockResolvedValue({
+44 -9
View File
@@ -1,7 +1,7 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent, CalendarRights } from './types';
import { JMAPMethodError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
import { getEffectiveTimeZone } from '../lib/calendar-timezone';
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
import {
@@ -417,11 +417,23 @@ export function resetSyntheticIdSupport(): void {
syntheticIdSupport.clear();
}
// The Calendar properties the app reads, which the default list on 0.16 and
// 1.0 covers. Stalwart 1.0 advertises JMAP Metadata but its handlers are
// still `todo!()`: a get that selects `metadata` or `privateMetadata` panics
// on the server. An explicit list keeps them out even if a later release
// adds them to the default. 0.16.6 knows every one of these.
const CALENDAR_PROPERTIES = [
'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault',
'myRights', 'shareWith', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime',
];
export async function getCalendars(): Promise<Calendar[]> {
const accountId = jmapClient.accountId;
const probeKey = syntheticIdProbeKey();
const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey);
const methodCalls: [string, Record<string, unknown>, string][] = [['Calendar/get', { accountId }, '0']];
const methodCalls: [string, Record<string, unknown>, string][] = [
['Calendar/get', { accountId, properties: CALENDAR_PROPERTIES }, '0'],
];
if (carriesProbe) methodCalls.push(syntheticIdProbeCall(accountId));
// The user's time zone is checked on the way too (calendarTimeZone).
const timeZone = getUserTimeZone();
@@ -443,7 +455,7 @@ export async function getCalendars(): Promise<Calendar[]> {
if (calendarAccessDenied.has(sharedAccountId)) return [];
try {
const sharedRes = await jmapClient.request(
[['Calendar/get', { accountId: sharedAccountId }, '0']],
[['Calendar/get', { accountId: sharedAccountId, properties: CALENDAR_PROPERTIES }, '0']],
USING,
);
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
@@ -621,6 +633,28 @@ function splitRefusedChunk(chunk: RangeChunk, error: unknown): RangeChunk[] | nu
];
}
/**
* The most participants an event may have, from the account's calendars
* capability (`maxParticipantsPerEvent`; the primary account's when the
* account names none), or null when it is not advertised. Stalwart 1.0
* refuses to create or update an event with more, an answer to an
* invitation included, and counts the organizer; 0.16 advertises the same
* value without enforcing it.
*/
export function maxParticipantsPerEvent(accountId?: string): number | null {
if (!jmapClient.isConnected) return null;
const accounts = jmapClient.currentSession?.accounts;
for (const id of [accountId, jmapClient.accountId]) {
if (!id) continue;
const capability = accounts?.[id]?.accountCapabilities?.[CAPABILITIES.CALENDARS] as
| { maxParticipantsPerEvent?: unknown }
| undefined;
const max = capability?.maxParticipantsPerEvent;
if (typeof max === 'number' && max > 0) return max;
}
return null;
}
/**
* The longest range an expanded query may span, in seconds:
* `maxExpandedQueryDuration` of the account's calendars capability (the
@@ -939,12 +973,13 @@ export async function createEvent(
);
const result = methodResult<{
created?: Record<string, CalendarEvent>;
notCreated?: Record<string, { description?: string; type?: string }>;
notCreated?: Record<string, { description?: string; type?: string; properties?: string[] }>;
}>(res);
const created = result.created?.['new-event'];
if (!created) {
const err = result.notCreated?.['new-event'];
throw new Error(err?.description || err?.type || 'Failed to create event');
// With its type: the caller names Stalwart 1.0's participant limit.
throw new JMAPSetError(err?.type ?? 'setError', err?.description || err?.type || 'Failed to create event', err?.properties);
}
// The /set response echoes only server-set properties; merge them over the
// submitted payload so the store's optimistic insert keeps recurrence data.
@@ -1013,11 +1048,11 @@ export async function updateEvent(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string; properties?: string[] }> }>(res);
const err = result.notUpdated?.[id];
// Keep the SetError type: the store retries some refusals differently
// (isOverrideSeriesKeyRejection).
if (err) throw new JMAPMethodError(err.type ?? 'setError', err.description || err.type || 'Failed to update event');
if (err) throw new JMAPSetError(err.type ?? 'setError', err.description || err.type || 'Failed to update event', err.properties);
}
export async function deleteEvents(
@@ -1057,9 +1092,9 @@ export async function rsvpEvent(
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string; properties?: string[] }> }>(res);
const err = result.notUpdated?.[eventId];
if (err) throw new Error(err.description || err.type || 'Failed to send RSVP');
if (err) throw new JMAPSetError(err.type ?? 'setError', err.description || err.type || 'Failed to send RSVP', err.properties);
}
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).
+52 -5
View File
@@ -1,5 +1,6 @@
import React from 'react';
import {
Alert as AlertDialog,
Modal,
View,
Text,
@@ -24,6 +25,7 @@ import {
Users,
Trash2,
AlignLeft,
AlertTriangle,
} from 'lucide-react-native';
import { addHours, addMinutes, format } from 'date-fns';
import type {
@@ -42,7 +44,7 @@ import {
timePattern,
} from '../../lib/calendar-utils';
import { displayNow } from '../../lib/calendar-timezone';
import { editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times';
import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times';
import { useSettingsStore } from '../../stores/settings-store';
import { canCreateEventsIn } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
@@ -56,12 +58,15 @@ import {
type Reminder,
type ReminderUnit,
} from '../../lib/calendar-alerts';
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
import { buildRecurrenceSummary, isSimpleRecurrenceRule, recurrenceRulesEqual } from '../../lib/recurrence';
import {
participantFieldsToSave,
participantLimitMessage,
seedAttendees,
userOrganizes,
type Attendee,
} from '../../lib/calendar-participants';
import type { SeriesEdit } from '../../lib/recurrence-overrides';
import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
@@ -89,10 +94,14 @@ interface EventModalProps {
currentUserEmails?: string[];
// Client-side iCal subscriptions are read-only targets (#762).
isSubscriptionCalendar?: (calendarId: string) => boolean;
/** The server's maxParticipantsPerEvent, for a warning; null when not advertised. */
maxParticipants?: number | null;
onSave: (
data: Partial<CalendarEvent>,
calendarId: string,
options?: { sendSchedulingMessages?: boolean },
// `edit`: what the save knows beyond its patch, for the scope a series
// edit goes to (the attendee rows, what changed about its dates).
options?: { sendSchedulingMessages?: boolean; edit?: SeriesEdit },
) => void | Promise<void>;
onDelete?: (event: CalendarEvent) => void;
onClose: () => void;
@@ -159,6 +168,7 @@ export function EventModal({
defaultCalendarId,
currentUserEmails = [],
isSubscriptionCalendar,
maxParticipants = null,
onSave,
onDelete,
onClose,
@@ -295,6 +305,10 @@ export function EventModal({
}
};
// An event without an organizer, or organized under one of the user's
// addresses; not an invitation someone else sent.
const organizes = userOrganizes(event, currentUserEmails);
const handleSave = async () => {
if (!title.trim() || !calendarId) return;
setSaving(true);
@@ -332,11 +346,19 @@ export function EventModal({
locations: clearedOr(buildLocations(location), 'locations'),
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
};
// What changes about when the event happens: moved by the user as its
// organizer, the attendees are asked again. The dates are those of
// the event the editor was opened on - one occurrence of a series -
// whose own rule is not this save's to change.
const timesMoved = !!event && editorTimesChanged({ allDay, start, end }, event);
const ruleChanged = !!event && !recurrenceRulesEqual(event.recurrenceRules, recurrenceRules);
// An invitation the user received keeps its organizer; only an event
// without one is organized by the user.
Object.assign(data, participantFieldsToSave(event, attendees, {
userEmail: currentUserEmails[0],
userEmails: currentUserEmails,
resolveName: resolveContactName,
rescheduled: timesMoved || (!event?.recurrenceId && ruleChanged),
}));
if (data.recurrenceRules === null) {
// Dropping the rule leaves per-occurrence data dangling.
@@ -347,9 +369,17 @@ export function EventModal({
if (data[key] === undefined) delete data[key];
}
await onSave(data, calendarId, {
sendSchedulingMessages: attendees.length > 0 && sendInvitations,
// Only the organizer sends: on an invitation someone else sent, a
// save would reach the organizer as a reply.
sendSchedulingMessages: organizes && attendees.length > 0 && sendInvitations,
edit: { attendees, timesMoved, ruleChanged },
});
onClose();
} catch (err) {
AlertDialog.alert(
t('calendar.notifications.event_error', 'Something went wrong'),
participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined),
);
} finally {
setSaving(false);
}
@@ -729,7 +759,22 @@ export function EventModal({
setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase()))
}
/>
{attendees.length > 0 && (
{/* Only a warning: 0.16 advertises the limit without enforcing
it, Stalwart 1.0 refuses to save more participants - the
organizer entry a save adds counts too. */}
{maxParticipants != null && attendees.length > 0 && attendees.length + 1 > maxParticipants && (
<View style={styles.limitRow} accessibilityRole="alert">
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.limitText}>
{t(
'calendar.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.',
{ max: maxParticipants },
)}
</Text>
</View>
)}
{organizes && attendees.length > 0 && (
<View style={styles.allDayRow}>
<Text style={[styles.fieldText, { flex: 1 }]}>
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
@@ -895,6 +940,8 @@ function makeStyles(c: ThemePalette) {
justifyContent: 'center',
},
fieldText: { ...typography.body, color: c.text },
limitRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.xs },
limitText: { ...typography.caption, color: c.warning, flex: 1 },
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
@@ -25,6 +25,7 @@ import {
type InvitationTrustAssessment,
} from '../../lib/calendar-invitation';
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
import { participantLimitMessage } from '../../lib/calendar-participants';
import { canCreateEventsIn } from '../../lib/calendar-editability';
import { getCalendarColor, timePattern } from '../../lib/calendar-utils';
import { getDateFnsLocale } from '../../lib/calendar-locale';
@@ -211,8 +212,10 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
setRsvpStatus(status);
setNotice(t('calendar.invitation.response_sent', 'Response sent'));
setState('done');
} catch {
setNotice(t('calendar.invitation.response_error', 'Could not send your response'));
} catch (err) {
// Stalwart 1.0 refuses an answer, too, for a meeting over its
// participant limit: say so rather than "could not".
setNotice(participantLimitMessage(err, t) ?? t('calendar.invitation.response_error', 'Could not send your response'));
} finally {
setBusy(false);
}
@@ -230,8 +233,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
: t('calendar.invitation.already_in_calendar', 'Already in your calendar'),
);
setState('done');
} catch {
setNotice(t('calendar.invitation.add_error', 'Could not add the event'));
} catch (err) {
setNotice(participantLimitMessage(err, t) ?? t('calendar.invitation.add_error', 'Could not add the event'));
} finally {
setBusy(false);
}
+185 -3
View File
@@ -1,16 +1,23 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent } from '../../api/types';
import {
askAttendeesAgain,
buildParticipantMap,
collectUserCalendarAddresses,
getParticipantCount,
getParticipantList,
getStatusCounts,
getUserParticipantId,
hasAnsweredAttendees,
isOrganizer,
participantFieldsToSave,
participantLimitMessage,
participantLimitOf,
seedAttendees,
seriesParticipantFields,
userOrganizes,
} from '../calendar-participants';
import { JMAPSetError } from '../../api/jmap-result';
const event: Partial<CalendarEvent> = {
organizerCalendarAddress: 'mailto:Alice@example.com',
@@ -201,22 +208,47 @@ describe('participantFieldsToSave', () => {
me: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
},
};
const rows = seedAttendees(invitation, ['me@example.com']);
const rows = [...seedAttendees(invitation, ['me@example.com']), { name: 'Dan', email: 'dan@example.com' }];
const fields = participantFieldsToSave(invitation, rows, { userEmail: 'me@example.com' });
expect(owner(fields)).toMatchObject({ email: 'boss@example.com', name: 'Boss', calendarAddress: 'mailto:boss@example.com' });
expect(Object.values(fields.participants ?? {}).map((p) => p.email).sort()).toEqual(['boss@example.com', 'me@example.com']);
expect(Object.values(fields.participants ?? {}).map((p) => p.email).sort())
.toEqual(['boss@example.com', 'dan@example.com', 'me@example.com']);
expect(fields).not.toHaveProperty('organizerCalendarAddress');
});
it('writes no participants for an invitation whose list the user left alone', () => {
// An alert or a note on the user's copy: a written list would reach the
// organizer as a reply.
const invitation: Partial<CalendarEvent> = {
organizerCalendarAddress: 'mailto:boss@example.com',
participants: {
boss: { calendarAddress: 'mailto:boss@example.com', roles: { owner: true } },
me: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
bob: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'declined' },
},
};
const fields = participantFieldsToSave(invitation, seedAttendees(invitation, ['me@example.com']), {
userEmail: 'me@example.com',
rescheduled: true,
});
expect(fields).toEqual({});
});
it('keeps the exact address a series is organized under', () => {
const series: Partial<CalendarEvent> = {
organizerCalendarAddress: 'mailto:Team@Example.com',
participants: { org: { calendarAddress: 'mailto:Team@Example.com' }, bob: { email: 'bob@example.com' } },
};
const fields = participantFieldsToSave(series, attendees, { userEmail: 'me@example.com' });
// The user organizes it under a group address in another letter case.
const fields = participantFieldsToSave(series, [...attendees, { name: 'Dan', email: 'dan@example.com' }], {
userEmail: 'me@example.com',
userEmails: ['me@example.com', 'team@example.com'],
});
expect(owner(fields)?.email).toBe('Team@Example.com');
expect(fields).not.toHaveProperty('organizerCalendarAddress');
@@ -253,3 +285,153 @@ describe('participantFieldsToSave', () => {
expect(participantFieldsToSave({ title: 'Lunch' }, [], { userEmail: 'me@example.com' })).toEqual({});
});
});
describe('answers on save (Stalwart 0.16 and 1.0)', () => {
// A meeting the user organizes: Bob accepted, Carol declined, Dan never answered.
const meeting: Partial<CalendarEvent> = {
organizerCalendarAddress: 'mailto:me@example.com',
participants: {
p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
p1: { calendarAddress: 'mailto:Bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
p2: { calendarAddress: 'mailto:carol@example.com', roles: { attendee: true }, participationStatus: 'declined' },
p3: { calendarAddress: 'mailto:dan@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
},
};
const me = { userEmail: 'me@example.com', userEmails: ['me@example.com'] };
const rows = () => seedAttendees(meeting, ['me@example.com']);
const byAddress = (fields: ReturnType<typeof participantFieldsToSave>) => Object.fromEntries(
Object.entries(fields.participants ?? {}).map(([id, p]) => [p.email!.toLowerCase(), { id, status: p.participationStatus }]),
);
it('keeps every attendee\'s id and answer, and asks a new one', () => {
const fields = participantFieldsToSave(meeting, [...rows(), { name: 'Eve', email: 'eve@example.com' }], me);
const after = byAddress(fields);
expect(after['me@example.com']).toEqual({ id: 'p0', status: 'accepted' });
expect(after['bob@example.com']).toEqual({ id: 'p1', status: 'accepted' });
expect(after['carol@example.com']).toEqual({ id: 'p2', status: 'declined' });
expect(after['dan@example.com']).toEqual({ id: 'p3', status: 'needs-action' });
expect(after['eve@example.com'].status).toBe('needs-action');
expect(after['eve@example.com'].id).not.toMatch(/^p\d$/);
});
it('asks the attendees again when the user as the organizer moves the meeting', () => {
const after = byAddress(participantFieldsToSave(meeting, rows(), { ...me, rescheduled: true }));
expect(after['me@example.com'].status).toBe('accepted');
expect(after['bob@example.com']).toEqual({ id: 'p1', status: 'needs-action' });
expect(after['carol@example.com']).toEqual({ id: 'p2', status: 'needs-action' });
});
it('counts any of the user\'s addresses as the organizer', () => {
const aliasMeeting = { ...meeting, organizerCalendarAddress: 'mailto:Alias@example.com' } as Partial<CalendarEvent>;
aliasMeeting.participants = { ...meeting.participants, p0: { ...meeting.participants!.p0, calendarAddress: 'mailto:Alias@example.com' } };
const after = byAddress(participantFieldsToSave(aliasMeeting, seedAttendees(aliasMeeting, ['me@example.com', 'alias@example.com']), {
userEmail: 'me@example.com', userEmails: ['me@example.com', 'alias@example.com'], rescheduled: true,
}));
expect(after['bob@example.com'].status).toBe('needs-action');
expect(userOrganizes(aliasMeeting, ['me@example.com', 'ALIAS@example.com'])).toBe(true);
expect(userOrganizes(aliasMeeting, ['me@example.com'])).toBe(false);
});
it('keeps an answer listed under a second entry of the same address', () => {
const map = buildParticipantMap({ name: '', email: 'me@example.com' }, [{ name: '', email: 'bob@example.com' }], {
a: { calendarAddress: 'mailto:bob@example.com', participationStatus: 'needs-action' },
b: { email: 'BOB@example.com', participationStatus: 'tentative' },
});
expect(Object.entries(map).find(([, p]) => p.email === 'bob@example.com')).toEqual([
'a', expect.objectContaining({ participationStatus: 'tentative' }),
]);
});
it('tells whether anyone but the organizer answered', () => {
expect(hasAnsweredAttendees(meeting.participants)).toBe(true);
expect(hasAnsweredAttendees(askAttendeesAgain(meeting.participants!))).toBe(false);
expect(hasAnsweredAttendees(null)).toBe(false);
});
it('asks everyone but the organizer again for a new series', () => {
const asked = askAttendeesAgain(meeting.participants!, ['me@example.com']);
expect(asked.p0.participationStatus).toBe('accepted');
expect([asked.p1, asked.p2, asked.p3].map((p) => p.participationStatus)).toEqual(['needs-action', 'needs-action', 'needs-action']);
});
});
describe('seriesParticipantFields (all occurrences)', () => {
const master: Partial<CalendarEvent> = {
organizerCalendarAddress: 'mailto:me@example.com',
participants: {
p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
p2: { calendarAddress: 'mailto:carol@example.com', roles: { attendee: true }, participationStatus: 'tentative' },
},
};
// This occurrence: Bob declined it, and Zoe was added to just this day.
const occurrence: Partial<CalendarEvent> = {
...master,
participants: {
...master.participants,
p1: { ...master.participants!.p1, participationStatus: 'declined' },
pz: { calendarAddress: 'mailto:zoe@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
},
};
const me = { userEmail: 'me@example.com', userEmails: ['me@example.com'] };
const rows = seedAttendees(occurrence, ['me@example.com']);
const statuses = (fields: ReturnType<typeof seriesParticipantFields>) => Object.fromEntries(
Object.values(fields.participants ?? {}).map((p) => [p.email, p.participationStatus]),
);
it('writes nothing when the user changed no attendee and did not move the series', () => {
expect(seriesParticipantFields(master, occurrence, rows, me)).toEqual({});
});
it('starts from the series\' own list and applies only the attendees added or removed', () => {
const edited = [...rows.filter((a) => a.email !== 'carol@example.com'), { name: '', email: 'eve@example.com' }];
const fields = seriesParticipantFields(master, occurrence, edited, me);
// Bob keeps the series' answer, not this day's; Zoe stays on her day only.
expect(statuses(fields)).toEqual({
'me@example.com': 'accepted',
'bob@example.com': 'accepted',
'eve@example.com': 'needs-action',
});
});
it('asks the series\' attendees again when the user moves it', () => {
const fields = seriesParticipantFields(master, occurrence, rows, { ...me, rescheduled: true });
expect(statuses(fields)).toEqual({
'me@example.com': 'accepted',
'bob@example.com': 'needs-action',
'carol@example.com': 'needs-action',
});
});
});
describe('participantLimitOf (Stalwart 1.0 maxParticipantsPerEvent)', () => {
it('reads the limit from a 1.0 refusal and ignores other failures', () => {
const refusal = new JMAPSetError('invalidProperties', 'An event cannot have more than 20 participants.', ['participants']);
expect(participantLimitOf(refusal)).toBe(20);
expect(participantLimitOf(new Error('Failed to create event'))).toBeNull();
expect(participantLimitOf(null)).toBeNull();
// An import that got nothing in carries the refusals as reasons.
expect(participantLimitOf(Object.assign(new Error('nothing imported'), {
refused: [{ reason: 'An event cannot have more than 5 participants.' }],
}))).toBe(5);
});
it('says the limit, counting the organizer, in the user\'s language', () => {
const t = (_key: string, fallback?: string, params?: Record<string, unknown>) =>
(fallback ?? '').replace('{max, plural, one {# participant} other {# participants}}', `${params?.max} participants`);
expect(participantLimitMessage(new Error('An event cannot have more than 20 participants.'), t))
.toBe('The server allows at most 20 participants per event, including the organizer, so it refused this change. An administrator can raise the limit.');
expect(participantLimitMessage(new Error('nope'), t)).toBeNull();
});
});
@@ -17,6 +17,7 @@ import {
import { getEventStartDate, getTaskDueDate, getTaskDueDisplayDate } from '../calendar-utils';
import {
durationBetween,
editorTimesChanged,
editorTimesFromEvent,
eventTimeFieldsToSave,
type EditorTimes,
@@ -248,3 +249,48 @@ describe('task editor round trip', () => {
});
});
});
describe('editorTimesChanged (whether a save moves the event)', () => {
// 08:00-09:00 in New York, shown in the Berlin calendar as 14:00-15:00.
const meeting = ev({
start: '2026-10-12T08:00:00', duration: 'PT1H', timeZone: 'America/New_York',
utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T13:00:00Z',
});
it('is no move while the form shows the event\'s own times, whatever zone it shows them in', () => {
expect(editorTimesChanged(editorTimesFromEvent(meeting), meeting)).toBe(false);
});
it('ignores the seconds the form cannot show', () => {
const withSeconds = ev({ ...meeting, utcStart: '2026-10-12T12:00:30Z', utcEnd: '2026-10-12T13:00:30Z' });
const times = editorTimesFromEvent(meeting);
expect(editorTimesChanged(times, withSeconds)).toBe(false);
});
it('is a move when the start or the end changes', () => {
const times = editorTimesFromEvent(meeting);
expect(editorTimesChanged(shift(times, 'both', 60), meeting)).toBe(true);
expect(editorTimesChanged(shift(times, 'end', 30), meeting)).toBe(true);
});
it('is a move when the event turns all-day or timed', () => {
expect(editorTimesChanged({ ...editorTimesFromEvent(meeting), allDay: true }, meeting)).toBe(true);
});
it('compares the first and last day of an all-day event', () => {
const holiday = ev({ start: '2026-10-12T00:00:00', duration: 'P2D', timeZone: null, showWithoutTime: true });
const times = editorTimesFromEvent(holiday);
expect(editorTimesChanged(times, holiday)).toBe(false);
expect(editorTimesChanged({ ...times, end: new Date(times.end.getTime() + 86_400_000) }, holiday)).toBe(true);
});
it('does not take a zero-length event for moved (the form keeps its end)', () => {
const reminder = ev({
start: '2026-10-12T08:00:00', duration: 'PT0S', timeZone: 'America/New_York',
utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T12:00:00Z',
});
const times = editorTimesFromEvent(reminder);
expect(times.end.getTime()).toBe(times.start.getTime());
expect(editorTimesChanged(times, reminder)).toBe(false);
});
});
@@ -1,5 +1,7 @@
import { describe, it, expect } from 'vitest';
import { parseISO } from 'date-fns';
import { expandRecurringEvents } from '../recurrence-expansion';
import { computeFireTime } from '../calendar-alert-scheduler';
import type { CalendarEvent, RecurrenceRule } from '../../api/types';
function ev(partial: Partial<CalendarEvent>): CalendarEvent {
@@ -400,3 +402,93 @@ describe('expandRecurringEvents - override patches', () => {
expect(result[0].locations?.l1.name).toBe('Room A');
});
});
// The master's utcStart/utcEnd place its first occurrence. An occurrence the
// device expands carries the instants worked out for it, or none, never the
// master's: kept, they put every later occurrence's reminders on the first
// one's date, where they are in the past and never scheduled.
describe('expandRecurringEvents - the master\'s instants', () => {
const fifteenHoursBefore = { '@type': 'OffsetTrigger', offset: '-PT15H', relativeTo: 'start' };
it('gives all-day occurrences no instants, so their reminders go off before each day (0.16.23 and 1.0)', () => {
// A yearly birthday, as both servers return the master in Berlin.
const birthday = ev({
id: 'bday', start: '2024-11-02T00:00:00', showWithoutTime: true, duration: 'P1D', timeZone: null,
utcStart: '2024-11-01T23:00:00Z', utcEnd: '2024-11-02T23:00:00Z',
recurrenceRules: [{ frequency: 'yearly' }],
});
const [occurrence] = expandRecurringEvents([birthday], '2026-10-01T00:00:00Z', '2026-12-01T00:00:00Z');
expect(occurrence.start).toBe('2026-11-02T00:00:00');
expect(occurrence).not.toHaveProperty('utcStart');
expect(occurrence).not.toHaveProperty('utcEnd');
expect(computeFireTime(occurrence, fifteenHoursBefore)).toBe(parseISO('2026-11-02T00:00:00').getTime() - 15 * 3600_000);
});
it('gives floating occurrences no instants when the master\'s are 0.16.23\'s year 32548', () => {
const standup = ev({
id: 'fl', start: '2026-10-05T09:00:00', duration: 'PT1H', timeZone: null,
utcStart: '32548-12-04T15:30:07Z', utcEnd: '32548-12-04T15:30:07Z',
recurrenceRules: [{ frequency: 'daily', count: 3 }],
});
const result = expandRecurringEvents([standup], '2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z');
expect(result.map((e) => e.start)).toEqual(['2026-10-05T09:00:00', '2026-10-06T09:00:00', '2026-10-07T09:00:00']);
for (const occurrence of result) {
expect(occurrence).not.toHaveProperty('utcStart');
expect(occurrence).not.toHaveProperty('utcEnd');
}
});
it('gives floating occurrences no instants when the master\'s are the next occurrence\'s (0.16.23)', () => {
// Once the first occurrence is overridden, 0.16.23 answers the master's
// utcStart/utcEnd with the next plain occurrence's.
const standup = ev({
id: 'fl', start: '2026-10-05T09:00:00', duration: 'PT1H', timeZone: null,
utcStart: '2026-10-06T07:00:00Z', utcEnd: '2026-10-06T08:00:00Z',
recurrenceRules: [{ frequency: 'daily', count: 3 }],
recurrenceOverrides: { '2026-10-05T09:00:00': { title: 'Kick-off' } },
});
const result = expandRecurringEvents([standup], '2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z');
expect(result.map((e) => [e.start, e.utcStart])).toEqual([
['2026-10-05T09:00:00', undefined],
['2026-10-06T09:00:00', undefined],
['2026-10-07T09:00:00', undefined],
]);
});
it('carries the master\'s offset to floating occurrences when its instants are its own (1.0)', () => {
// A floating 09:00 read in Berlin summer time: 07:00Z.
const standup = ev({
id: 'fl', start: '2026-10-05T09:00:00', duration: 'PT1H', timeZone: null,
utcStart: '2026-10-05T07:00:00Z', utcEnd: '2026-10-05T08:00:00Z',
recurrenceRules: [{ frequency: 'daily', count: 2 }],
});
const result = expandRecurringEvents([standup], '2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z');
expect(result.map((e) => [e.utcStart, e.utcEnd])).toEqual([
['2026-10-05T07:00:00.000Z', '2026-10-05T08:00:00.000Z'],
['2026-10-06T07:00:00.000Z', '2026-10-06T08:00:00.000Z'],
]);
});
it('times zoned occurrences from the duration when the master\'s instants are another occurrence\'s (0.16.23)', () => {
const standup = ev({
id: 'tz', start: '2026-10-05T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
utcStart: '2026-10-06T07:00:00Z', utcEnd: '2026-10-06T08:30:00Z',
recurrenceRules: [{ frequency: 'daily', count: 2 }],
});
const result = expandRecurringEvents([standup], '2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z');
expect(result.map((e) => [e.utcStart, e.utcEnd])).toEqual([
['2026-10-05T07:00:00.000Z', '2026-10-05T08:00:00.000Z'],
['2026-10-06T07:00:00.000Z', '2026-10-06T08:00:00.000Z'],
]);
});
});
@@ -1,11 +1,14 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent } from '../../api/types';
import {
buildAllScopeEdit,
buildAllScopeUpdates,
buildFutureSeriesData,
buildFutureSeriesEdit,
isRecurringSeriesMember,
truncateRecurrenceRules,
} from '../recurrence-overrides';
import { seedAttendees } from '../calendar-participants';
const master: CalendarEvent = {
id: 'ev-1',
@@ -109,3 +112,72 @@ describe('buildFutureSeriesData', () => {
expect('recurrenceRules' in data).toBe(false);
});
});
describe('series edits from an occurrence the user organizes', () => {
const participants = {
p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
} as CalendarEvent['participants'];
const series: CalendarEvent = { ...master, organizerCalendarAddress: 'mailto:me@example.com', participants };
// The fourth occurrence, stretched to an hour, which Bob declined.
const fourth: CalendarEvent = {
...series,
id: 'ev-1:2026-03-05T09:00:00',
start: '2026-03-05T09:00:00',
duration: 'PT1H',
recurrenceId: '2026-03-05T09:00:00',
participants: { ...participants, p1: { ...participants!.p1, participationStatus: 'declined' } },
};
const me = { userEmail: 'me@example.com', userEmails: ['me@example.com'] };
// What the editor saves for an unchanged form: the occurrence's own times,
// the rule written back and the occurrence's participants.
const formSave = {
title: 'Standup (moved room)',
start: fourth.start,
duration: fourth.duration,
timeZone: fourth.timeZone,
showWithoutTime: false,
recurrenceRules: [{ frequency: 'daily', interval: 1, count: 10 }],
participants: fourth.participants,
} as Partial<CalendarEvent>;
const attendees = seedAttendees(fourth, ['me@example.com']);
it('changes only what the user changed when nothing moved (all occurrences)', () => {
const update = buildAllScopeEdit(formSave, fourth, series, { attendees, timesMoved: false, ruleChanged: false }, me);
// Not the fourth's length, rule rewrite or participants (Bob's decline).
expect(update).toEqual({ title: 'Standup (moved room)' });
});
it('moves the series by the occurrence\'s delta and asks its attendees again (all occurrences)', () => {
const moved = { ...formSave, start: '2026-03-05T10:00:00' };
const update = buildAllScopeEdit(moved, fourth, series, { attendees, timesMoved: true, ruleChanged: false }, me);
expect(update.start).toBe('2026-03-02T10:00:00');
const statuses = Object.values(update.participants ?? {}).map((p) => [p.calendarAddress, p.participationStatus]);
expect(statuses).toEqual([['mailto:me@example.com', 'accepted'], ['mailto:bob@example.com', 'needs-action']]);
});
it('asks every attendee of the new series again (this and following)', () => {
const data = buildFutureSeriesEdit(series, series.recurrenceRules!, fourth, formSave, ['me@example.com']);
const statuses = Object.values(data.participants ?? {}).map((p) => p.participationStatus);
expect(statuses).toEqual(['accepted', 'needs-action']);
});
it('leaves the answers of a series someone else organizes alone (this and following)', () => {
const invitation = { ...series, organizerCalendarAddress: 'mailto:boss@example.com', participants: {
...participants, p0: { ...participants!.p0, calendarAddress: 'mailto:boss@example.com' },
} } as CalendarEvent;
const data = buildFutureSeriesEdit(invitation, invitation.recurrenceRules!, fourth, {}, ['me@example.com']);
expect(Object.values(data.participants ?? {}).map((p) => p.participationStatus)).toEqual(['accepted', 'accepted']);
});
it('keeps writing the times when the flags are not given', () => {
expect(buildAllScopeUpdates({ start: '2026-03-05T09:00:00', duration: 'PT1H' }, fourth, series))
.toEqual({ duration: 'PT1H' });
});
});
@@ -30,3 +30,23 @@ describe('recurrence summary', () => {
expect(buildRecurrenceSummary(rule({ frequency: 'hourly' }), t)).toBeNull();
});
});
describe('recurrenceRulesEqual', () => {
it('takes the editor\'s rewrite of an unchanged rule for the server\'s normalised copy', async () => {
const { recurrenceRulesEqual } = await import('../recurrence');
const stored = [{
'@type': 'RecurrenceRule' as const, frequency: 'weekly', interval: 1, rscale: 'gregorian', skip: 'omit',
firstDayOfWeek: 'mo', byDay: [],
}];
expect(recurrenceRulesEqual(stored, [{ frequency: 'weekly', interval: 1 }])).toBe(true);
expect(recurrenceRulesEqual(undefined, null)).toBe(true);
expect(recurrenceRulesEqual(stored, [{ frequency: 'daily', interval: 1 }])).toBe(false);
expect(recurrenceRulesEqual(stored, [{ frequency: 'weekly', interval: 2 }])).toBe(false);
expect(recurrenceRulesEqual(stored, [{ frequency: 'weekly', count: 5 }])).toBe(false);
expect(recurrenceRulesEqual(stored, undefined)).toBe(false);
expect(recurrenceRulesEqual(
[{ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] }],
[{ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'fr' }] }],
)).toBe(false);
});
});
+188 -8
View File
@@ -1,4 +1,5 @@
import type { CalendarEvent, Participant } from '../api/types';
import type { MessageParams } from '../i18n/format';
import { generateUUID } from './uuid';
// Port of the webmail's lib/calendar-participants.ts.
@@ -244,18 +245,93 @@ export interface Attendee {
email: string;
}
type ParticipationStatus = Participant['participationStatus'];
/**
* The limit a refusal names when the server turned an event down for having
* too many participants, or null for any other failure. Stalwart 1.0 enforces
* maxParticipantsPerEvent on every create and update, answers to invitations
* included, and refuses with invalidProperties "An event cannot have more
* than 20 participants."; 0.16 advertises the limit without enforcing it.
* An import that got nothing in carries the refusals as `refused` reasons.
*/
export function participantLimitOf(error: unknown): number | null {
const texts: string[] = [];
if (error instanceof Error) texts.push(error.message);
const refused = (error as { refused?: { reason?: unknown }[] } | null)?.refused;
if (Array.isArray(refused)) {
for (const entry of refused) if (typeof entry?.reason === 'string') texts.push(entry.reason);
}
for (const text of texts) {
const match = /more than (\d+) participants/i.exec(text);
if (match) return Number(match[1]);
}
return null;
}
type Translate = (key: string, fallback?: string, params?: MessageParams) => string;
/**
* What to tell the user about a failed calendar change the server refused
* for the participant limit, in the language of `t`; null for any other
* failure.
*/
export function participantLimitMessage(error: unknown, t: Translate): string | null {
const max = participantLimitOf(error);
if (max === null) return null;
return t(
'calendar.notifications.participant_limit',
'The server allows at most {max, plural, one {# participant} other {# participants}} per event, including the organizer, so it refused this change. An administrator can raise the limit.',
{ max },
);
}
/**
* An event's participants by address (lowercased): the id each was first
* listed under, and its answer - a real one over a missing or needs-action
* one when an address is listed twice (see getParticipantList).
*/
function participantsByAddress(
participants: CalendarEvent['participants'] | null | undefined,
): Map<string, { id: string; status: ParticipationStatus | undefined }> {
const known = new Map<string, { id: string; status: ParticipationStatus | undefined }>();
for (const [id, p] of Object.entries(participants ?? {})) {
const key = getParticipantEmail(p).trim().toLowerCase();
if (!key) continue;
const seen = known.get(key);
if (!seen) known.set(key, { id, status: p.participationStatus });
else if ((!seen.status || seen.status === 'needs-action') && p.participationStatus) seen.status = p.participationStatus;
}
return known;
}
/**
* The participants an editor save writes: the organizer, then each attendee
* once. `existing` is the event's current map. An address already in it
* keeps its participant id and its answer, so a save neither resets the
* attendees' answers - the user's own on an invitation included - nor leaves
* a recurrence override pointing at a participant id that is gone. With
* `askAgain` every attendee but the organizer answers anew (needs-action).
*/
export function buildParticipantMap(
organizer: { name: string; email: string },
attendees: Attendee[],
existing?: CalendarEvent['participants'] | null,
options: { askAgain?: boolean } = {},
): Record<string, Participant> {
const participants: Record<string, Participant> = {};
const generateId = () => generateUUID();
const known = participantsByAddress(existing);
const idFor = (key: string) => {
const id = known.get(key)?.id;
return id && !(id in participants) ? id : generateUUID();
};
// calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis
// (implemented by Stalwart); the RFC 8984 sendTo property is retired there and
// stored as an inert JSPROP, so it is intentionally not sent.
participants[generateId()] = {
const organizerKey = organizer.email.trim().toLowerCase();
const organizerStatus = known.get(organizerKey)?.status;
participants[idFor(organizerKey)] = {
'@type': 'Participant',
name: organizer.name,
email: organizer.email,
@@ -264,7 +340,7 @@ export function buildParticipantMap(
// scheduling emits the organizer as an ATTENDEE line in addition to the
// ORGANIZER line, so the recipient sees the organizer listed twice.
roles: { owner: true },
participationStatus: 'accepted',
participationStatus: organizerStatus && organizerStatus !== 'needs-action' ? organizerStatus : 'accepted',
scheduleAgent: 'server',
expectReply: false,
kind: 'individual',
@@ -272,7 +348,7 @@ export function buildParticipantMap(
// The organizer already has an entry above, and an address must not appear
// twice in the invite list, so drop both cases case-insensitively (#731).
const seen = new Set<string>([organizer.email.trim().toLowerCase()]);
const seen = new Set<string>([organizerKey]);
attendees.forEach((a) => {
const email = a.email.trim();
@@ -280,13 +356,13 @@ export function buildParticipantMap(
if (!key || seen.has(key)) return;
seen.add(key);
participants[generateId()] = {
participants[idFor(key)] = {
'@type': 'Participant',
name: a.name,
email,
calendarAddress: `mailto:${email}`,
roles: { attendee: true },
participationStatus: 'needs-action',
participationStatus: (options.askAgain ? undefined : known.get(key)?.status) ?? 'needs-action',
scheduleAgent: 'server',
expectReply: true,
kind: 'individual',
@@ -309,11 +385,39 @@ function eventOrganizer(event: Partial<CalendarEvent>): { name: string; email: s
return email ? { name: '', email } : null;
}
/**
* Whether a save of `event` is the user's as its organizer: an event without
* an organizer yet becomes the user's, and so is one organized under any of
* the user's addresses. An invitation someone else sent is not: its
* organizer asks, the user only answers.
*/
export function userOrganizes(event: Partial<CalendarEvent> | null | undefined, userEmails: string[]): boolean {
const organizer = event ? eventOrganizer(event) : null;
if (!organizer) return true;
const key = organizer.email.toLowerCase();
return userEmails.some((address) => address.trim().toLowerCase() === key);
}
/** Whether two attendee lists name the same addresses, whatever the order or letter case. */
function sameAddresses(a: Attendee[], b: Attendee[]): boolean {
const keys = (list: Attendee[]) => new Set(list.map((x) => x.email.trim().toLowerCase()).filter(Boolean));
const left = keys(a);
const right = keys(b);
return left.size === right.size && [...left].every((key) => right.has(key));
}
export interface ParticipantSaveContext {
/** The user's first calendar address: the organizer of invitations sent from the editor. */
/** The user's first calendar address: the organizer of an event that has none yet. */
userEmail: string | undefined;
/** All of the user's calendar addresses (identities, aliases): whether the user organizes the event. */
userEmails?: string[];
/** A name for an organizer the event data carries none for (the contact card, the account). */
resolveName?: (email: string) => string | undefined;
/**
* The save changes when the event happens: its start or end, whether it is
* all-day, or its recurrence rule.
*/
rescheduled?: boolean;
}
/**
@@ -325,6 +429,15 @@ export interface ParticipantSaveContext {
* occurrences). Only an event without an organizer gets the user, and only
* then `organizerCalendarAddress` is written: Stalwart derives the iCalendar
* ORGANIZER solely from it, so without one no invitations go out.
* Participants that stay keep their id and answer (buildParticipantMap),
* unless the user as the organizer moves the event: then the other
* attendees are asked again, as RFC 5546 has an organizer do when an event
* is rescheduled - Stalwart's update to them carries each stored answer,
* which would read as an answer to the new time.
*
* A save of an invitation someone else organizes writes no participants
* while the user left them as they were: the organizer's list is not the
* user's to send, and a written list would reach the organizer as a reply.
*
* Without attendees, an event that had participants loses them, its
* organizer and the retired `replyTo` older releases wrote.
@@ -335,6 +448,9 @@ export function participantFieldsToSave(
context: ParticipantSaveContext,
): Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress' | 'replyTo'>> {
const { userEmail, resolveName } = context;
const userEmails = [userEmail, ...(context.userEmails ?? [])].filter((address): address is string => !!address);
const organizes = userOrganizes(event, userEmails);
if (event && !organizes && sameAddresses(attendees, seedAttendees(event, userEmails))) return {};
if (attendees.length > 0 && userEmail) {
const organizer = (event ? eventOrganizer(event) : null) ?? { name: '', email: userEmail };
const fields: Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress'>> = {
@@ -343,6 +459,8 @@ export function participantFieldsToSave(
// organizer name.
{ name: organizer.name || resolveName?.(organizer.email) || '', email: organizer.email },
attendees,
event?.participants,
{ askAgain: !!context.rescheduled && organizes },
),
};
if (!event?.organizerCalendarAddress) fields.organizerCalendarAddress = `mailto:${organizer.email}`;
@@ -358,6 +476,68 @@ export function participantFieldsToSave(
return {};
}
/**
* The participant fields a save to a whole series writes when the editor was
* opened on one of its occurrences ("All events"). The occurrence's list may
* differ from the series' - one occurrence declined, one attendee added to
* just that day - so the series starts from its own participants and takes
* only the attendees the user added or removed in the editor. Nothing is
* written when the save changes none of them and does not move the series.
*/
export function seriesParticipantFields(
master: Partial<CalendarEvent>,
occurrence: Partial<CalendarEvent>,
attendees: Attendee[],
context: ParticipantSaveContext,
): Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress' | 'replyTo'>> {
const userEmails = [context.userEmail, ...(context.userEmails ?? [])].filter((a): a is string => !!a);
const key = (a: Attendee) => a.email.trim().toLowerCase();
const before = seedAttendees(occurrence, userEmails);
const kept = new Set(attendees.map(key));
const removed = new Set(before.map(key).filter((k) => !kept.has(k)));
const had = new Set(before.map(key));
const added = attendees.filter((a) => !had.has(key(a)));
const askAgain = !!context.rescheduled && userOrganizes(master, userEmails);
if (added.length === 0 && removed.size === 0 && !askAgain) return {};
const series = seedAttendees(master, userEmails).filter((a) => !removed.has(key(a)));
const listed = new Set(series.map(key));
for (const a of added) if (!listed.has(key(a))) series.push(a);
return participantFieldsToSave(master, series, context);
}
/**
* `participants` with every attendee but the organizer asked anew
* (needs-action). For a new series cut off an existing one ("This and
* following events"): Stalwart sends a new event only to attendees who have
* not answered, so an answer carried over would keep it from them.
*/
export function askAttendeesAgain(
participants: Record<string, Participant>,
organizerEmails: string[] = [],
): Record<string, Participant> {
const organizers = new Set(organizerEmails.map((address) => address.trim().toLowerCase()));
const result: Record<string, Participant> = {};
for (const [id, p] of Object.entries(participants)) {
const address = getParticipantEmail(p).trim().toLowerCase();
const organizer = !!p.roles?.owner || (!!address && organizers.has(address));
result[id] = organizer ? p : { ...p, participationStatus: 'needs-action' };
}
return result;
}
/**
* Whether an attendee (anyone but the organizer) of `participants` has
* answered: Stalwart delivers a new occurrence override, and a new event,
* only to attendees who have not.
*/
export function hasAnsweredAttendees(participants: CalendarEvent['participants'] | null | undefined): boolean {
return Object.values(participants ?? {}).some((p) => {
if (p.roles?.owner) return false;
const status = p.participationStatus;
return !!status && status !== 'needs-action';
});
}
/**
* The attendee rows an editor should start from: every participant except the
* organizer (who is re-added by buildParticipantMap on save), without
+16 -1
View File
@@ -1,4 +1,4 @@
import { format, parseISO } from 'date-fns';
import { format, isSameDay, parseISO } from 'date-fns';
import type { CalendarEvent } from '../api/types';
import {
buildAllDayDuration,
@@ -42,6 +42,21 @@ export function editorTimesFromEvent(event: CalendarEvent): EditorTimes {
};
}
/**
* Whether the editor's times change when `event` happens: it turns all-day or
* timed, or its start or end moves - for an all-day event its first or last
* day, for a timed one the minute the editor shows. The editor keeps a time
* the user did not touch as it came, a zero-length event included, so an
* unchanged form is never a move.
*/
export function editorTimesChanged(times: EditorTimes, event: CalendarEvent): boolean {
const seed = editorTimesFromEvent(event);
if (seed.allDay !== times.allDay) return true;
if (times.allDay) return !isSameDay(seed.start, times.start) || !isSameDay(seed.end, times.end);
const minute = (date: Date) => Math.floor(date.getTime() / 60_000);
return minute(seed.start) !== minute(times.start) || minute(seed.end) !== minute(times.end);
}
export type EventTimeFields = Pick<CalendarEvent, 'start' | 'duration' | 'timeZone' | 'showWithoutTime'>;
function pad(n: number, len = 2): string {
+59 -26
View File
@@ -157,11 +157,10 @@ function isInRange(
): boolean {
const moved = !!override && Object.keys(override).some((key) => TIMING_KEYS.has(key.split('/', 1)[0]));
if (!moved) return date >= rangeStart && date < rangeEnd;
const timed = !occurrence.showWithoutTime;
const start = (timed ? validDate(occurrence.utcStart) : null) ?? validDate(occurrence.start);
const own = occurrence.showWithoutTime ? null : plausibleInstants(occurrence);
const start = own?.start ?? validDate(occurrence.start);
if (!start) return false;
const end = (timed ? validDate(occurrence.utcEnd) : null)
?? new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000);
const end = own?.end ?? new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000);
if (end <= start) return start >= rangeStart && start < rangeEnd;
return start < rangeEnd && end > rangeStart;
}
@@ -173,6 +172,43 @@ function validDate(value: string | null | undefined): Date | null {
return isNaN(date.getTime()) ? null : date;
}
// UTC-12 to UTC+14: the furthest an instant is from the wall clock it names.
const MAX_ZONE_OFFSET_MS = 14 * 60 * 60 * 1000;
/** The wall-clock fields of a parsed LocalDateTime, read as UTC. */
function wallAsUtc(wall: Date): number {
return Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
);
}
/** `value` parsed, when it is within a time zone's offset of the wall clock `wallMs`. */
function instantNear(value: string | null | undefined, wallMs: number): Date | null {
const instant = validDate(value);
return instant && Math.abs(instant.getTime() - wallMs) <= MAX_ZONE_OFFSET_MS ? instant : null;
}
/**
* An event's utcStart and utcEnd, where they can be its own: each within a
* time zone's offset of the wall-clock start and end, the end not before the
* start. `end` is null when only the start passes. For a series' master
* 0.16.23 has answered both with "32548-12-04T15:30:07Z" once every
* occurrence was overridden or excluded, and with the next plain
* occurrence's times once the first one was.
*/
function plausibleInstants(
event: Pick<CalendarEvent, 'start' | 'duration' | 'utcStart' | 'utcEnd'>,
): { start: Date; end: Date | null } | null {
const wall = validDate(event.start);
if (!wall) return null;
const wallStart = wallAsUtc(wall);
const start = instantNear(event.utcStart, wallStart);
if (!start) return null;
const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000);
return { start, end: end && end >= start ? end : null };
}
// Cache Intl formatters per IANA timezone id — constructing them is expensive
// and expansion runs over hundreds of occurrences. `null` marks ids the
// runtime rejected so we don't retry them.
@@ -221,13 +257,10 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number
export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
const formatter = getTzFormatter(timeZone);
if (!formatter) return null;
const wallAsUtc = Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
);
let guess = wallAsUtc;
const wallMs = wallAsUtc(wall);
let guess = wallMs;
for (let i = 0; i < 2; i++) {
guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess);
guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess);
}
return new Date(guess);
}
@@ -258,16 +291,15 @@ function createOccurrence(
const wall = parseISO(start);
if (!occurrence.showWithoutTime && !isNaN(wall.getTime())) {
// Unless the override sets its own duration, the master's length: from
// its instants, else from its duration. 0.16.23 has been seen to return
// instants in the year 32548 for a series' master, which don't parse.
// its instants when they are its own (plausibleInstants), else from its
// duration.
let durationMs: number | null = null;
const ownDuration = override && 'duration' in override ? parseDurationSeconds(occurrence.duration) : null;
const masterStart = validDate(master.utcStart);
const masterEnd = validDate(master.utcEnd);
const masterInstants = plausibleInstants(master);
if (ownDuration !== null) {
durationMs = ownDuration * 1000;
} else if (masterStart && masterEnd) {
durationMs = masterEnd.getTime() - masterStart.getTime();
} else if (masterInstants?.end) {
durationMs = masterInstants.end.getTime() - masterInstants.start.getTime();
} else {
const seconds = parseDurationSeconds(master.duration);
if (seconds !== null) durationMs = seconds * 1000;
@@ -283,21 +315,24 @@ function createOccurrence(
if (durationMs !== null) {
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
}
} else if (masterStart && master.start) {
} else if (masterInstants) {
// Floating events (or an unrecognized timezone id): keep the master's
// offset, which by definition doesn't vary.
const offsetMs = masterStart.getTime() - parseISO(master.start).getTime();
if (!isNaN(offsetMs)) {
utcStart = new Date(wall.getTime() + offsetMs).toISOString();
if (durationMs !== null) {
utcEnd = new Date(wall.getTime() + offsetMs + durationMs).toISOString();
}
const offsetMs = masterInstants.start.getTime() - parseISO(master.start).getTime();
utcStart = new Date(wall.getTime() + offsetMs).toISOString();
if (durationMs !== null) {
utcEnd = new Date(wall.getTime() + offsetMs + durationMs).toISOString();
}
}
}
// The master's utcStart/utcEnd place its first occurrence, not this one:
// an occurrence carries the instants worked out for it, or none - an
// all-day one, or one whose master's instants are not its own. Kept, they
// put the reminders of every later occurrence on the first one's date.
const { utcStart: _masterStart, utcEnd: _masterEnd, ...series } = occurrence;
const result: CalendarEvent = {
...occurrence,
...series,
id: `${master.id}:${recurrenceId}`,
originalId: master.originalId || master.id,
uid: master.uid,
@@ -308,11 +343,9 @@ function createOccurrence(
recurrenceOverrides: master.recurrenceOverrides,
excludedRecurrenceRules: master.excludedRecurrenceRules,
};
// The master's own instants place the first occurrence, not this one.
if (utcStart) {
result.utcStart = utcStart;
if (utcEnd) result.utcEnd = utcEnd;
else delete result.utcEnd;
}
return result;
}
+26 -1
View File
@@ -553,7 +553,7 @@ function knownOverrides(occurrence: Partial<CalendarEvent>): OverrideMap | null
}
/** True when a change to `occurrence` creates its override, false when one exists or it is unknown. */
function createsOverride(occurrence: Partial<CalendarEvent>): boolean {
export function createsOverride(occurrence: Partial<CalendarEvent>): boolean {
const overrides = knownOverrides(occurrence);
if (overrides === undefined || !occurrence.recurrenceId) return false;
const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides);
@@ -709,3 +709,28 @@ export function buildOccurrenceRsvpPatch(
};
return withNewOverrideDetails(occurrence, patch as Partial<CalendarEvent>, options);
}
/**
* The two writes, on the base event, of a first change to an occurrence the
* device expanded that keeps its attendees' answers: `quiet` creates the
* override as the occurrence stands (to be sent without scheduling), then
* `change` rewrites that override with the change. Stalwart sends a new
* override only to attendees who have not answered; an update of one goes to
* everyone. Null when the occurrence cannot be keyed.
*/
export function buildOverrideInTwoSteps(
instance: OccurrenceOverrideContext & Pick<CalendarEvent, 'duration'>,
patch: Partial<CalendarEvent>,
): { quiet: Record<string, unknown>; change: Record<string, unknown> } | null {
const key = resolveOverrideKey(instance, instance.recurrenceOverrides);
if (!key) return null;
const entry = mergeOverrideEntry({}, {
start: instance.start,
duration: instance.duration,
...overrideEntryFields(withNewOverrideDetails(instance, {}, { seriesKeys: true })),
});
return {
quiet: overrideEntryPatch(instance, key, entry),
change: { [`recurrenceOverrides/${key}`]: mergeOverrideEntry(entry, overrideEntryFields(patch)) },
};
}
+84 -1
View File
@@ -1,5 +1,13 @@
import { format, parseISO } from 'date-fns';
import type { CalendarEvent, RecurrenceRule } from '../api/types';
import {
askAttendeesAgain,
getEventOrganizerEmails,
seriesParticipantFields,
userOrganizes,
type Attendee,
type ParticipantSaveContext,
} from './calendar-participants';
/** Identity / whole-series keys that must never go into a single-occurrence override. */
export const RECURRENCE_OVERRIDE_IMMUTABLE_KEYS = [
@@ -53,20 +61,40 @@ export function truncateRecurrenceRules(
});
}
/** What an editor save changed about when the event happens. */
export interface ScheduleChanges {
/** Its start, end or all-day. */
timesMoved?: boolean;
/** Its recurrence rule. */
ruleChanged?: boolean;
}
/**
* Updates for the "All events" scope. The editor seeds its date fields from
* the tapped occurrence, so an unchanged `start` must not be written to the
* master (that would move the whole series onto the occurrence's date and
* make the earlier occurrences vanish). When the user did move the occurrence,
* the same delta is applied to the master's start instead.
* the same delta is applied to the master's start instead. With
* `changes.timesMoved` false none of the occurrence's times reach the master
* - a moved or resized occurrence would move or resize every one - and with
* `changes.ruleChanged` false neither does the rule the editor wrote back.
*/
export function buildAllScopeUpdates(
updates: Partial<CalendarEvent>,
occurrence: Pick<CalendarEvent, 'start' | 'recurrenceId'>,
master: Pick<CalendarEvent, 'start'>,
changes: ScheduleChanges = {},
): Partial<CalendarEvent> {
const out: Partial<CalendarEvent> = { ...updates };
delete (out as Record<string, unknown>).recurrenceId;
if (changes.ruleChanged === false) delete out.recurrenceRules;
if (changes.timesMoved === false) {
delete out.start;
delete out.duration;
delete out.timeZone;
delete out.showWithoutTime;
return out;
}
if (!out.start) return out;
if (!occurrence.recurrenceId) return out; // editing the master itself
const nextStart = parseISO(out.start);
@@ -140,3 +168,58 @@ export function buildFutureSeriesData(
}
return data;
}
/** An editor save of one occurrence, for the scope it is applied to. */
export interface SeriesEdit extends ScheduleChanges {
/** The attendee rows the user left. */
attendees: Attendee[];
}
/**
* The update of a whole series ("All events") from an editor opened on one of
* its occurrences: its times only when the user moved the occurrence (and
* then by the same delta), its rule only when the user changed it, and its
* participants from the series' own list with the attendees the user added
* or removed (seriesParticipantFields) - never the occurrence's list, whose
* answers are that day's. A series the user organizes and moves asks its
* attendees again.
*/
export function buildAllScopeEdit(
updates: Partial<CalendarEvent>,
occurrence: CalendarEvent,
master: CalendarEvent,
edit: SeriesEdit,
context: ParticipantSaveContext,
): Partial<CalendarEvent> {
const out = buildAllScopeUpdates(updates, occurrence, master, edit);
delete out.participants;
delete out.organizerCalendarAddress;
delete out.replyTo;
return {
...out,
...seriesParticipantFields(master, occurrence, edit.attendees, {
...context,
rescheduled: !!edit.timesMoved || !!edit.ruleChanged,
}),
};
}
/**
* The new series of "This and following events", as buildFutureSeriesData
* makes it. It is a new event (a new UID) to its attendees, and Stalwart
* sends a new event only to attendees who have not answered: when the user
* organizes it, everyone but the organizer is asked anew.
*/
export function buildFutureSeriesEdit(
master: CalendarEvent,
originalRules: RecurrenceRule[] | null,
occurrence: Pick<CalendarEvent, 'start' | 'recurrenceId'>,
updates: Partial<CalendarEvent>,
userEmails: string[],
): Partial<CalendarEvent> {
const data = buildFutureSeriesData(master, originalRules, occurrence, updates);
if (data.participants && userOrganizes(data, userEmails)) {
data.participants = askAttendeesAgain(data.participants, getEventOrganizerEmails(data));
}
return data;
}
+35
View File
@@ -127,6 +127,41 @@ export function isSimpleRecurrenceRule(rule: RecurrenceRule): boolean {
);
}
// A rule as the occurrences it makes: JSCalendar defaults filled in, empty
// lists left out, so an editor's rewrite of an unchanged rule compares equal
// to the server's normalised copy.
function ruleKey(rule: RecurrenceRule): string {
const list = <T,>(values: T[] | undefined) => (values?.length ? values : undefined);
return JSON.stringify({
frequency: rule.frequency.toLowerCase(),
interval: rule.interval || 1,
until: rule.until || undefined,
count: rule.count || undefined,
byDay: list(rule.byDay?.map((d) => `${d.nthOfPeriod ?? ''}${d.day.toLowerCase()}`)),
byMonth: list(rule.byMonth),
byMonthDay: list(rule.byMonthDay),
byYearDay: list(rule.byYearDay),
byWeekNo: list(rule.byWeekNo),
byHour: list(rule.byHour),
byMinute: list(rule.byMinute),
bySecond: list(rule.bySecond),
bySetPosition: list(rule.bySetPosition),
firstDayOfWeek: (rule.firstDayOfWeek || 'mo').toLowerCase(),
rscale: (rule.rscale || 'gregorian').toLowerCase(),
skip: (rule.skip || 'omit').toLowerCase(),
});
}
/** Whether two recurrence rule lists make the same occurrences (none equals none). */
export function recurrenceRulesEqual(
a: RecurrenceRule[] | null | undefined,
b: RecurrenceRule[] | null | undefined,
): boolean {
const left = (a ?? []).map(ruleKey);
const right = (b ?? []).map(ruleKey);
return left.length === right.length && left.every((key, i) => key === right[i]);
}
/**
* Human-readable summary of a recurrence rule, e.g.
* "Every 2 months on the third Thursday · 12 occurrences", in the app
+36 -10
View File
@@ -92,11 +92,15 @@ import {
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
import {
buildAllScopeUpdates,
buildFutureSeriesData,
buildAllScopeEdit,
buildFutureSeriesEdit,
isRecurringSeriesMember,
truncateRecurrenceRules,
type SeriesEdit,
} from '../lib/recurrence-overrides';
import { participantLimitMessage } from '../lib/calendar-participants';
import { maxParticipantsPerEvent } from '../api/calendar';
import { useContactNameResolver } from '../lib/contact-name-resolver';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
@@ -116,6 +120,8 @@ type PendingAction =
updates: Partial<CalendarEvent>;
calendarId: string;
sendScheduling?: boolean;
// The attendee rows and date changes the scope builds its write from.
edit?: SeriesEdit;
}
| { kind: 'delete'; event: CalendarEvent }
| {
@@ -159,6 +165,7 @@ export default function CalendarScreen() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const currentUserEmails = useUserCalendarAddresses();
const resolveContactName = useContactNameResolver();
const { locale } = useCalendarLocale();
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
@@ -651,7 +658,7 @@ export default function CalendarScreen() {
} catch (err) {
Alert.alert(
t('calendar.notifications.rsvp_error', 'Failed to update response'),
err instanceof Error ? err.message : undefined,
participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined),
);
}
return;
@@ -660,6 +667,11 @@ export default function CalendarScreen() {
if (action.kind === 'edit') {
const { updates } = action;
const opts = { sendSchedulingMessages: action.sendScheduling };
const participantContext = {
userEmail: currentUserEmails[0],
userEmails: currentUserEmails,
resolveName: resolveContactName,
};
switch (scope) {
case 'this': {
// The store keeps the change on this occurrence: through its
@@ -672,7 +684,7 @@ export default function CalendarScreen() {
const result = await truncateRecurrenceAtEvent(event);
if (!result) throw new Error('Master event not found');
const { master, originalRules } = result;
const newEventData = buildFutureSeriesData(master, originalRules, event, updates);
const newEventData = buildFutureSeriesEdit(master, originalRules, event, updates, currentUserEmails);
delete newEventData.calendarIds;
const targetCalendarId =
action.calendarId || getPrimaryCalendarId(master) || '';
@@ -692,7 +704,10 @@ export default function CalendarScreen() {
case 'all': {
const master = await getMasterEvent(event);
if (!master) throw new Error('Master event not found');
await updateEvent(master.id, buildAllScopeUpdates(updates, event, master), opts);
const changes = action.edit
? buildAllScopeEdit(updates, event, master, action.edit, participantContext)
: updates;
await updateEvent(master.id, changes, opts);
break;
}
}
@@ -720,7 +735,7 @@ export default function CalendarScreen() {
} catch (err) {
Alert.alert(
t('calendar.notifications.event_error', 'Something went wrong'),
err instanceof Error ? err.message : undefined,
participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined),
);
}
try {
@@ -729,14 +744,17 @@ export default function CalendarScreen() {
// Best effort — the next navigation refetches anyway.
}
},
[pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, truncateRecurrenceAtEvent, refresh, submitRsvp, t],
[
pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, truncateRecurrenceAtEvent, refresh,
submitRsvp, t, currentUserEmails, resolveContactName,
],
);
const handleSave = React.useCallback(
async (
data: Partial<CalendarEvent>,
calendarId: string,
options?: { sendSchedulingMessages?: boolean },
options?: { sendSchedulingMessages?: boolean; edit?: SeriesEdit },
) => {
const sendScheduling = options?.sendSchedulingMessages;
if (modalEvent) {
@@ -749,7 +767,7 @@ export default function CalendarScreen() {
if (isRecurringSeriesMember(modalEvent)) {
// Ask which occurrences the edit applies to; the actual write
// happens in handleScopeSelect.
setPendingAction({ kind: 'edit', event: modalEvent, updates, calendarId, sendScheduling });
setPendingAction({ kind: 'edit', event: modalEvent, updates, calendarId, sendScheduling, edit: options?.edit });
return;
}
await updateEvent(modalEvent.id, updates, { sendSchedulingMessages: sendScheduling });
@@ -1164,7 +1182,14 @@ export default function CalendarScreen() {
setPendingAction({ kind: 'rsvp', event: ev, participantId, status });
return;
}
await submitRsvp(ev, participantId, status, 'series');
try {
await submitRsvp(ev, participantId, status, 'series');
} catch (err) {
Alert.alert(
t('calendar.notifications.rsvp_error', 'Failed to update response'),
participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined),
);
}
}}
/>
@@ -1175,6 +1200,7 @@ export default function CalendarScreen() {
defaultDate={modalDate}
currentUserEmails={currentUserEmails}
isSubscriptionCalendar={isSubscriptionCalendar}
maxParticipants={maxParticipantsPerEvent(modalEvent?.accountId)}
onSave={handleSave}
onDelete={handleDeleteFromModal}
onClose={() => setModalVisible(false)}
@@ -0,0 +1,226 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Changing one occurrence of a meeting the user organizes. Stalwart 0.16.6
// to 1.0 send a new occurrence override - the first change to an occurrence
// - only to attendees who have not answered (no PARTSTAT or NEEDS-ACTION,
// crates/groupware scheduling/organizer.rs), so a change that keeps the
// answers goes out in two writes: the override as the occurrence stands,
// quietly, then the change as an update of it, which reaches everyone.
// And while a series uses its calendar's default alerts, 1.0 shows the
// defaults in place of an occurrence's own and refuses useDefaultAlerts on
// one occurrence: the series stops using them first.
vi.mock('../../api/calendar', () => ({
getCalendars: vi.fn(),
queryEvents: vi.fn(async () => []),
getEvents: vi.fn(),
getEventIdentities: vi.fn(async () => []),
scanCalendarObjects: vi.fn(async () => []),
isCalendarAccessDenied: vi.fn(() => false),
noteCalendarAccessError: vi.fn(() => false),
resetCalendarAccessDenied: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(async () => undefined),
deleteEvents: vi.fn(),
batchCreateEvents: vi.fn(),
setDefaultCalendar: vi.fn(),
rsvpEvent: vi.fn(),
supportsSyntheticCalendarIds: vi.fn(async () => false),
queryExpandedEvents: vi.fn(),
hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events),
resetSyntheticIdSupport: vi.fn(),
resetTimeZoneChecks: vi.fn(),
}));
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', isConnected: true },
}));
import * as calendarApi from '../../api/calendar';
import type { CalendarEvent } from '../../api/types';
import { useCalendarStore } from '../calendar-store';
const mockUpdate = vi.mocked(calendarApi.updateEvent);
const mockGetEvents = vi.mocked(calendarApi.getEvents);
const mockQueryExpanded = vi.mocked(calendarApi.queryExpandedEvents);
const RID = '2026-03-02T09:00:00';
const alert15 = { '@type': 'Alert' as const, trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' };
const alert30 = { '@type': 'Alert' as const, trigger: { '@type': 'OffsetTrigger', offset: '-PT30M', relativeTo: 'start' }, action: 'display' };
// Organized by the user; Bob accepted the series.
const participants = {
p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
};
const askedAgain = { ...participants, p1: { ...participants.p1, participationStatus: 'needs-action' } };
// Expanded by the server (a synthetic id), its series' overrides hydrated: none yet.
const serverOccurrence = (partial: Partial<CalendarEvent> = {}): CalendarEvent => ({
id: 's1', baseEventId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H',
title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null,
calendarIds: { 'cal-1': true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1,
utcStart: '2026-03-02T08:00:00Z',
...partial,
} as unknown as CalendarEvent);
// Expanded on the device (0.16 without synthetic ids): changes become
// overrides on the base event.
const deviceOccurrence = (partial: Partial<CalendarEvent> = {}): CalendarEvent => ({
id: `base:${RID}`, originalId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H',
title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null,
calendarIds: { 'cal-1': true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1,
...partial,
} as unknown as CalendarEvent);
beforeEach(() => {
vi.clearAllMocks();
useCalendarStore.getState().reset();
useCalendarStore.setState({ calendars: [{ id: 'cal-1', name: 'Work' }] as never, loadedRange: null });
mockGetEvents.mockResolvedValue([]);
mockQueryExpanded.mockResolvedValue([]);
});
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.
mockQueryExpanded.mockResolvedValue(['s9', 'other']);
mockGetEvents.mockImplementation(async (ids: string[]) => (ids.includes('s9')
? [
{ id: 'other', baseEventId: 'else', recurrenceId: RID, start: RID } as CalendarEvent,
{ id: 's9', baseEventId: 'base', recurrenceId: RID, start: RID, duration: 'PT1H' } as CalendarEvent,
]
: []));
await useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial<CalendarEvent>, {
sendSchedulingMessages: true,
});
expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([
['s1', { title: 'Standup', participants, sequence: 1 }, false],
['s9', { title: 'Standup, room 2', participants }, true],
]);
// Looked up around the occurrence, expanded.
expect(mockGetEvents).toHaveBeenCalledWith(['s9', 'other'], undefined, { expanded: true });
});
it('sends the change once when it asks the attendees again (a move)', async () => {
useCalendarStore.setState({ events: [serverOccurrence()] });
await useCalendarStore.getState().updateEvent('s1', {
start: '2026-03-02T10:00:00', participants: askedAgain,
} as Partial<CalendarEvent>, { sendSchedulingMessages: true });
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0]).toEqual(['s1', expect.objectContaining({ participants: askedAgain }), true, undefined]);
});
it('sends the change once to an occurrence that has its override already', async () => {
useCalendarStore.setState({ events: [serverOccurrence({ recurrenceOverrides: { [RID]: { title: 'Standup' } } })] });
await useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial<CalendarEvent>, {
sendSchedulingMessages: true,
});
expect(mockUpdate.mock.calls.map(([id, , schedule]) => [id, schedule])).toEqual([['s1', true]]);
});
it('writes once without scheduling', async () => {
useCalendarStore.setState({ events: [serverOccurrence()] });
await useCalendarStore.getState().updateEvent('s1', { title: 'My note' }, { sendSchedulingMessages: false });
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 () => {
useCalendarStore.setState({ events: [serverOccurrence()] });
mockQueryExpanded.mockResolvedValue(null);
await useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial<CalendarEvent>, {
sendSchedulingMessages: true,
});
expect(mockUpdate.mock.calls.map(([id, , schedule]) => [id, schedule])).toEqual([['s1', false], ['s1', true]]);
});
});
describe('a first change to one occurrence expanded on the device (Stalwart 0.16.23)', () => {
it('writes the override on the base event as it stands, then rewrites it with the change', async () => {
useCalendarStore.setState({ events: [deviceOccurrence()] });
await useCalendarStore.getState().updateEvent(`base:${RID}`, {
title: 'Standup, room 2', participants,
} as Partial<CalendarEvent>, { sendSchedulingMessages: true });
const entry = {
start: RID, duration: 'PT1H', title: 'Standup', participants, sequence: 1,
organizerCalendarAddress: 'mailto:me@example.com',
};
expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([
['base', { recurrenceOverrides: { [RID]: entry } }, false],
['base', { [`recurrenceOverrides/${RID}`]: { ...entry, title: 'Standup, room 2' } }, true],
]);
});
it('writes one override when the attendees are asked again', async () => {
useCalendarStore.setState({ events: [deviceOccurrence()] });
await useCalendarStore.getState().updateEvent(`base:${RID}`, {
start: '2026-03-02T10:00:00', participants: askedAgain,
} as Partial<CalendarEvent>, { sendSchedulingMessages: true });
expect(mockUpdate).toHaveBeenCalledTimes(1);
});
});
describe('an occurrence\'s alerts on a series that uses the calendar\'s default alerts', () => {
const edit = { alerts: { 'reminder-1': alert30 }, useDefaultAlerts: false } as Partial<CalendarEvent>;
it('turns the defaults off for the series first, keeping what it shows under ids of its own (Stalwart 1.0)', async () => {
useCalendarStore.setState({ events: [serverOccurrence({ participants: undefined })] });
// 1.0 fills the defaults into `alerts` under the default alerts' ids.
mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'base'
? [{ id: 'base', useDefaultAlerts: true, alerts: { 'default-1': alert15 } } as unknown as CalendarEvent]
: []));
await useCalendarStore.getState().updateEvent('s1', edit);
expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([
['base', { useDefaultAlerts: false, alerts: { 'reminder-1': alert15 } }, false],
['s1', expect.objectContaining({ alerts: { 'reminder-1': alert30 } }), undefined],
]);
expect(mockUpdate.mock.calls[1][1]).not.toHaveProperty('useDefaultAlerts');
});
it('turns the defaults off on the base event of an occurrence expanded on the device (Stalwart 0.16.23)', async () => {
useCalendarStore.setState({ events: [deviceOccurrence({ participants: undefined, organizerCalendarAddress: undefined })] });
mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'base'
? [{ id: 'base', useDefaultAlerts: true, alerts: {} } as unknown as CalendarEvent]
: []));
await useCalendarStore.getState().updateEvent(`base:${RID}`, edit);
expect(mockUpdate.mock.calls[0]).toEqual(['base', { useDefaultAlerts: false, alerts: null }, false, undefined]);
expect(mockUpdate.mock.calls[1][0]).toBe('base');
expect(mockUpdate.mock.calls[1][1]).toHaveProperty('recurrenceOverrides');
});
it('leaves a series without default alerts alone', async () => {
useCalendarStore.setState({ events: [serverOccurrence({ participants: undefined })] });
mockGetEvents.mockResolvedValue([{ id: 'base', useDefaultAlerts: false, alerts: {} } as unknown as CalendarEvent]);
await useCalendarStore.getState().updateEvent('s1', edit);
expect(mockUpdate.mock.calls.map(([id]) => id)).toEqual(['s1']);
});
});
+109 -9
View File
@@ -4,7 +4,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { createPersistStorage } from './persist-storage';
import { t } from './locale-store';
import type { Calendar, CalendarEvent, CalendarRights, Participant, StateChange } from '../api/types';
import { normalizeAllDayDuration } from '../lib/calendar-utils';
import { getEventStartDate, normalizeAllDayDuration } from '../lib/calendar-utils';
import { withOwnAlertIds } from '../lib/calendar-alerts';
import { hasAnsweredAttendees } from '../lib/calendar-participants';
import {
type CalendarUpdates,
type EventIdentityFields,
@@ -42,6 +44,8 @@ import {
buildFallbackOverridePatch,
buildOccurrencePatch,
buildOccurrenceRsvpPatch,
buildOverrideInTwoSteps,
createsOverride,
isBrowserExpandedOccurrence,
isOverrideSeriesKeyRejection,
isServerRecurrenceInstance,
@@ -513,6 +517,65 @@ async function updateOccurrence(
await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId);
}
/**
* The server occurrence of `instance`'s series at the same recurrence id, read
* again: a new override can give an occurrence another synthetic id.
*/
async function findServerOccurrence(
instance: CalendarEvent,
accountId: string | undefined,
): Promise<CalendarEvent | null> {
if (!instance.baseEventId || !instance.recurrenceId) return null;
const at = getEventStartDate(instance).getTime();
if (isNaN(at)) return null;
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;
}
/**
* A first change to one server occurrence that keeps its attendees' answers.
* Stalwart delivers a new override only to attendees who have not answered
* (organizer.rs, 0.16 and 1.0 alike), and one who has would never hear of the
* 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.
*/
async function updateOccurrenceInTwoSteps(
instance: CalendarEvent,
syntheticId: string,
patch: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
): Promise<void> {
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);
}
/**
* Turn the calendar's default alerts off for a whole series, keeping the
* alerts it shows as its own - before one occurrence's alerts change in a way
* that turns them off (reminderFieldsToSave). While a series uses them,
* Stalwart 1.0 shows the calendar's defaults in place of any occurrence's own
* alerts and refuses useDefaultAlerts on a single occurrence ("This property
* cannot be modified on a single occurrence."): the change was stored and
* never shown. 0.16 stores and shows it either way; its series keeps the
* same alerts.
*/
async function stopSeriesDefaultAlerts(baseId: string, accountId: string | undefined): Promise<void> {
const [base] = await fetchEvents([baseId], accountId);
if (!base?.useDefaultAlerts) return;
const shown = base.alerts ?? {};
await apiUpdateEvent(baseId, {
useDefaultAlerts: false,
// Under ids of their own: 1.0 drops alerts saved under a default alert's id.
alerts: Object.keys(shown).length > 0 ? withOwnAlertIds(Object.values(shown), Object.keys(shown)) : null,
}, false, accountId);
}
/** Destroy one server-expanded occurrence; falls back to excluding it on the base event. */
async function destroyOccurrence(
instance: CalendarEvent,
@@ -550,6 +613,23 @@ async function updateBrowserOccurrence(
await updateBaseEvent(target.realId, patch, sendSchedulingMessages, target.accountId);
}
/**
* A first change to one occurrence the device expanded that keeps its
* attendees' answers: the override on the base event is written first as
* the occurrence stands, without scheduling, then updated with the change
* (see updateOccurrenceInTwoSteps).
*/
async function updateBrowserOccurrenceInTwoSteps(
target: MutationTarget,
updates: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
): Promise<void> {
const steps = buildOverrideInTwoSteps(target.storeEvent!, updates);
if (!steps) throw new Error('Cannot resolve the occurrence to override');
await updateBaseEvent(target.realId, steps.quiet, false, target.accountId);
await updateBaseEvent(target.realId, steps.change, sendSchedulingMessages, target.accountId);
}
/** Delete one occurrence the device expanded by excluding it on its base event. */
async function destroyBrowserOccurrence(
target: MutationTarget,
@@ -852,16 +932,36 @@ export const useCalendarStore = create<CalendarState>()(
: undefined);
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId);
// An occurrence's alerts that turn the calendar's defaults off.
const stopsDefaultAlerts = patch.useDefaultAlerts === false && 'alerts' in patch;
// A first change to one occurrence that goes out to attendees of whom
// some answered and keep their answers (see updateOccurrenceInTwoSteps).
const twoSteps = !!storeEvent && schedule === true && createsOverride(storeEvent)
&& hasAnsweredAttendees('participants' in patch
? patch.participants as CalendarEvent['participants']
: storeEvent.participants);
if (target.isOccurrence && storeEvent) {
await updateOccurrence(
storeEvent,
realId,
(options) => withNewOverrideDetails(storeEvent, patch as Partial<CalendarEvent>, options),
schedule,
accountId,
);
if (stopsDefaultAlerts && storeEvent.baseEventId) {
await stopSeriesDefaultAlerts(storeEvent.baseEventId, accountId);
}
if (twoSteps) {
await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial<CalendarEvent>, schedule, accountId);
} else {
await updateOccurrence(
storeEvent,
realId,
(options) => withNewOverrideDetails(storeEvent, patch as Partial<CalendarEvent>, options),
schedule,
accountId,
);
}
} else if (target.isBrowserOccurrence) {
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule);
if (stopsDefaultAlerts) await stopSeriesDefaultAlerts(realId, accountId);
if (twoSteps) {
await updateBrowserOccurrenceInTwoSteps(target, patch as Partial<CalendarEvent>, schedule);
} else {
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule);
}
} else {
await updateBaseEvent(realId, patch, schedule, accountId);
}