mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
fix: offer to save an event when the server refuses its invitations
From 0.16.21 Stalwart fails the whole CalendarEvent/set with `forbidden` when the account may not send scheduling messages, so an event with attendees was not saved at all and the calendar showed only "Failed to save event". Such a refusal now surfaces as SchedulingDeniedError, and the calendar names the server's reason and offers to save the event without sending the invitations.
This commit is contained in:
@@ -63,6 +63,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } fro
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error";
|
||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||
import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
@@ -842,45 +843,59 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
jumpTo(eventDate);
|
||||
}, [jumpTo]);
|
||||
|
||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, requestedScheduling?: boolean) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
try {
|
||||
if (editEvent) {
|
||||
if (isRecurringEvent(editEvent)) {
|
||||
setPendingScopeAction({
|
||||
type: "edit",
|
||||
event: editEvent,
|
||||
updates: data,
|
||||
sendScheduling: sendSchedulingMessages,
|
||||
});
|
||||
setShowEventModal(false);
|
||||
setEditEvent(null);
|
||||
return;
|
||||
}
|
||||
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
||||
if (data.start) {
|
||||
focusCalendarOnEvent({ start: data.start });
|
||||
}
|
||||
toast.success(t("notifications.event_updated"));
|
||||
} else {
|
||||
const created = await createEvent(client, data, sendSchedulingMessages);
|
||||
if (!created) {
|
||||
toast.error(t("notifications.event_error"));
|
||||
return;
|
||||
}
|
||||
focusCalendarOnEvent(created);
|
||||
if (sendSchedulingMessages) {
|
||||
toast.success(t("notifications.invitation_sent"));
|
||||
const save = async (sendSchedulingMessages: boolean | undefined): Promise<void> => {
|
||||
try {
|
||||
if (editEvent) {
|
||||
if (isRecurringEvent(editEvent)) {
|
||||
setPendingScopeAction({
|
||||
type: "edit",
|
||||
event: editEvent,
|
||||
updates: data,
|
||||
sendScheduling: sendSchedulingMessages,
|
||||
});
|
||||
setShowEventModal(false);
|
||||
setEditEvent(null);
|
||||
return;
|
||||
}
|
||||
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
||||
if (data.start) {
|
||||
focusCalendarOnEvent({ start: data.start });
|
||||
}
|
||||
toast.success(t("notifications.event_updated"));
|
||||
} else {
|
||||
toast.success(t("notifications.event_created"));
|
||||
const created = await createEvent(client, data, sendSchedulingMessages);
|
||||
if (!created) {
|
||||
toast.error(t("notifications.event_error"));
|
||||
return;
|
||||
}
|
||||
focusCalendarOnEvent(created);
|
||||
if (sendSchedulingMessages) {
|
||||
toast.success(t("notifications.invitation_sent"));
|
||||
} else {
|
||||
toast.success(t("notifications.event_created"));
|
||||
}
|
||||
}
|
||||
setShowEventModal(false);
|
||||
setEditEvent(null);
|
||||
} catch (error) {
|
||||
// The server refuses to send the invitations (Stalwart 0.16.21+ fails
|
||||
// the whole save then). Offer to keep the event without them.
|
||||
if (error instanceof SchedulingDeniedError && sendSchedulingMessages) {
|
||||
const ok = await confirmAction({
|
||||
title: t("notifications.invitations_denied_title"),
|
||||
message: t("notifications.invitations_denied", { reason: error.reason }),
|
||||
confirmText: t("notifications.save_without_invitations"),
|
||||
});
|
||||
if (ok) await save(false);
|
||||
return;
|
||||
}
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
setShowEventModal(false);
|
||||
setEditEvent(null);
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, t]);
|
||||
};
|
||||
await save(requestedScheduling);
|
||||
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, t]);
|
||||
|
||||
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { JMAPClient, SchedulingDeniedError } from '../jmap/client';
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
|
||||
// Stalwart 0.16.21+ fails the whole CalendarEvent/set with `forbidden` when
|
||||
// the account may not send iTIP messages; the UI can then save without them.
|
||||
function clientRefusing(type: string) {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
||||
Object.assign(client, { accountId: 'me' });
|
||||
vi.spyOn(client as unknown as { getCalendarsAccountId: () => string }, 'getCalendarsAccountId').mockReturnValue('me');
|
||||
vi.spyOn(client as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request')
|
||||
.mockImplementation(async (calls: Call[]) => {
|
||||
const [, args, id] = calls[0];
|
||||
const error = { type, description: 'Scheduling is not allowed for this account' };
|
||||
return {
|
||||
methodResponses: [['CalendarEvent/set', args.create
|
||||
? { notCreated: { 'new-event': error } }
|
||||
: { notUpdated: Object.fromEntries(Object.keys(args.update as object).map((k) => [k, error])) }, id]],
|
||||
};
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
describe('calendar scheduling refusals', () => {
|
||||
it('reports a refused invitation as SchedulingDeniedError', async () => {
|
||||
const client = clientRefusing('forbidden');
|
||||
await expect(client.createCalendarEvent({ title: 'Meet' }, true)).rejects.toBeInstanceOf(SchedulingDeniedError);
|
||||
await expect(client.updateCalendarEvent('e1', { title: 'Meet' }, true)).rejects.toThrow(SchedulingDeniedError);
|
||||
});
|
||||
|
||||
it('keeps other failures as plain errors', async () => {
|
||||
await expect(clientRefusing('forbidden').createCalendarEvent({ title: 'Meet' }, false))
|
||||
.rejects.not.toBeInstanceOf(SchedulingDeniedError);
|
||||
await expect(clientRefusing('invalidProperties').createCalendarEvent({ title: 'Meet' }, true))
|
||||
.rejects.not.toBeInstanceOf(SchedulingDeniedError);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,9 @@ import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectio
|
||||
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
|
||||
import { decodeFileNodeName } from "./filenode-name";
|
||||
import { contactFromWire, contactToWire } from "./contact-wire";
|
||||
import { SchedulingDeniedError } from "./scheduling-error";
|
||||
|
||||
export { SchedulingDeniedError };
|
||||
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
|
||||
import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone";
|
||||
import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
|
||||
@@ -6719,6 +6722,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
debug.warn('calendar', 'CalendarEvent/create invalid properties', error.properties);
|
||||
debug.warn('calendar', 'CalendarEvent/create sent keys', Object.keys(cleanEvent));
|
||||
debug.groupEnd();
|
||||
if (sendSchedulingMessages && error.type === 'forbidden') {
|
||||
throw new SchedulingDeniedError(error.description || 'forbidden');
|
||||
}
|
||||
throw new Error(error.description || "Failed to create calendar event");
|
||||
}
|
||||
|
||||
@@ -6909,6 +6915,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (result.notUpdated?.[eventId]) {
|
||||
const error = result.notUpdated[eventId];
|
||||
debug.error('CalendarEvent/set notUpdated', { eventId, error });
|
||||
if (sendSchedulingMessages && error.type === 'forbidden') {
|
||||
throw new SchedulingDeniedError(error.description || 'forbidden');
|
||||
}
|
||||
throw new Error(error.description || "Failed to update calendar event");
|
||||
}
|
||||
debug.log('calendar', 'CalendarEvent/set update full response', { methodName, result });
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The server refused the change because of the iTIP messages it would send
|
||||
* (Stalwart 0.16.21+ fails the whole CalendarEvent/set with `forbidden`
|
||||
* when the account may not send scheduling messages). The same change
|
||||
* without `sendSchedulingMessages` can still be saved.
|
||||
*/
|
||||
export class SchedulingDeniedError extends Error {
|
||||
constructor(readonly reason: string) {
|
||||
super(reason);
|
||||
this.name = 'SchedulingDeniedError';
|
||||
}
|
||||
}
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "يبدأ الآن",
|
||||
"alert_in_minutes": "خلال {count} دقيقة",
|
||||
"invitation_sent": "تم إرسال الدعوات",
|
||||
"invitations_denied_title": "تعذر إرسال الدعوات",
|
||||
"invitations_denied": "رفض الخادم إرسال الدعوات: {reason}. هل تريد حفظ الحدث دون إرسالها؟",
|
||||
"save_without_invitations": "حفظ دون دعوات",
|
||||
"rsvp_updated": "تم تحديث الرد",
|
||||
"rsvp_error": "فشل تحديث الرد",
|
||||
"event_duplicated": "تم تكرار الحدث",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Comença ara",
|
||||
"alert_in_minutes": "D'aquí a {count} min",
|
||||
"invitation_sent": "Invitacions enviades",
|
||||
"invitations_denied_title": "No es poden enviar les invitacions",
|
||||
"invitations_denied": "El servidor ha refusat enviar les invitacions: {reason}. Voleu desar l'esdeveniment sense enviar-les?",
|
||||
"save_without_invitations": "Desa sense invitacions",
|
||||
"rsvp_updated": "Resposta actualitzada",
|
||||
"rsvp_error": "No s'ha pogut actualitzar la resposta",
|
||||
"event_duplicated": "Esdeveniment duplicat",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "Začíná nyní",
|
||||
"alert_in_minutes": "Za {count} min",
|
||||
"invitation_sent": "Pozvánky byly odeslány",
|
||||
"invitations_denied_title": "Pozvánky nelze odeslat",
|
||||
"invitations_denied": "Server odmítl odeslat pozvánky: {reason}. Uložit událost bez jejich odeslání?",
|
||||
"save_without_invitations": "Uložit bez pozvánek",
|
||||
"rsvp_updated": "Odpověď byla aktualizována",
|
||||
"rsvp_error": "Aktualizace odpovědi selhala",
|
||||
"event_duplicated": "Událost byla duplikována",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Starter nu",
|
||||
"alert_in_minutes": "Om {count} min",
|
||||
"invitation_sent": "Invitationer sendt",
|
||||
"invitations_denied_title": "Invitationerne kan ikke sendes",
|
||||
"invitations_denied": "Serveren afviste at sende invitationerne: {reason}. Gem begivenheden uden at sende dem?",
|
||||
"save_without_invitations": "Gem uden invitationer",
|
||||
"rsvp_updated": "Svar opdateret",
|
||||
"rsvp_error": "Kunne ikke opdatere svar",
|
||||
"event_duplicated": "Begivenhed duplikeret",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "Beginnt jetzt",
|
||||
"alert_in_minutes": "In {count} Min.",
|
||||
"invitation_sent": "Einladungen gesendet",
|
||||
"invitations_denied_title": "Einladungen können nicht gesendet werden",
|
||||
"invitations_denied": "Der Server hat das Senden der Einladungen abgelehnt: {reason}. Termin ohne Einladungen speichern?",
|
||||
"save_without_invitations": "Ohne Einladungen speichern",
|
||||
"rsvp_updated": "Antwort aktualisiert",
|
||||
"rsvp_error": "Antwort konnte nicht aktualisiert werden",
|
||||
"event_duplicated": "Termin dupliziert",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Starting now",
|
||||
"alert_in_minutes": "In {count} min",
|
||||
"invitation_sent": "Invitations sent",
|
||||
"invitations_denied_title": "Invitations can't be sent",
|
||||
"invitations_denied": "The server refused to send the invitations: {reason}. Save the event without sending them?",
|
||||
"save_without_invitations": "Save without invitations",
|
||||
"rsvp_updated": "Response updated",
|
||||
"rsvp_error": "Failed to update response",
|
||||
"event_duplicated": "Event duplicated",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "Comienza ahora",
|
||||
"alert_in_minutes": "En {count} min",
|
||||
"invitation_sent": "Invitaciones enviadas",
|
||||
"invitations_denied_title": "No se pueden enviar las invitaciones",
|
||||
"invitations_denied": "El servidor rechazó enviar las invitaciones: {reason}. ¿Guardar el evento sin enviarlas?",
|
||||
"save_without_invitations": "Guardar sin invitaciones",
|
||||
"rsvp_updated": "Respuesta actualizada",
|
||||
"rsvp_error": "Error al actualizar la respuesta",
|
||||
"event_duplicated": "Evento duplicado",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "همین الان شروع میشود",
|
||||
"alert_in_minutes": "{count} دقیقه دیگر",
|
||||
"invitation_sent": "دعوتنامهها ارسال شد",
|
||||
"invitations_denied_title": "ارسال دعوتنامهها ممکن نیست",
|
||||
"invitations_denied": "سرور از ارسال دعوتنامهها خودداری کرد: {reason}. رویداد بدون ارسال آنها ذخیره شود؟",
|
||||
"save_without_invitations": "ذخیره بدون دعوتنامه",
|
||||
"rsvp_updated": "پاسخ بهروزرسانی شد",
|
||||
"rsvp_error": "بهروزرسانی پاسخ ناموفق بود",
|
||||
"event_duplicated": "رویداد کپی شد",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Commence maintenant",
|
||||
"alert_in_minutes": "Dans {count} min",
|
||||
"invitation_sent": "Invitations envoyées",
|
||||
"invitations_denied_title": "Impossible d'envoyer les invitations",
|
||||
"invitations_denied": "Le serveur a refusé d'envoyer les invitations : {reason}. Enregistrer l'événement sans les envoyer ?",
|
||||
"save_without_invitations": "Enregistrer sans invitations",
|
||||
"rsvp_updated": "Réponse mise à jour",
|
||||
"rsvp_error": "Échec de la mise à jour de la réponse",
|
||||
"event_duplicated": "Événement dupliqué",
|
||||
|
||||
@@ -2933,6 +2933,9 @@
|
||||
"alert_now": "מתחיל עכשיו",
|
||||
"alert_in_minutes": "ב-{count}דקות",
|
||||
"invitation_sent": "נשלחו הזמנות",
|
||||
"invitations_denied_title": "לא ניתן לשלוח את ההזמנות",
|
||||
"invitations_denied": "השרת סירב לשלוח את ההזמנות: {reason}. לשמור את האירוע בלי לשלוח אותן?",
|
||||
"save_without_invitations": "שמור בלי הזמנות",
|
||||
"rsvp_updated": "התגובה עודכנה",
|
||||
"rsvp_error": "עדכון התגובה נכשל",
|
||||
"event_duplicated": "האירוע שוכפל",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Most kezdődik",
|
||||
"alert_in_minutes": "{count} perc múlva",
|
||||
"invitation_sent": "Meghívók elküldve",
|
||||
"invitations_denied_title": "A meghívók nem küldhetők el",
|
||||
"invitations_denied": "A kiszolgáló nem küldte el a meghívókat: {reason}. Mentsük az eseményt a meghívók nélkül?",
|
||||
"save_without_invitations": "Mentés meghívók nélkül",
|
||||
"rsvp_updated": "Válasz frissítve",
|
||||
"rsvp_error": "Nem sikerült frissíteni a választ",
|
||||
"event_duplicated": "Esemény duplikálva",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "Inizia ora",
|
||||
"alert_in_minutes": "Tra {count} min",
|
||||
"invitation_sent": "Inviti inviati",
|
||||
"invitations_denied_title": "Impossibile inviare gli inviti",
|
||||
"invitations_denied": "Il server ha rifiutato di inviare gli inviti: {reason}. Salvare l'evento senza inviarli?",
|
||||
"save_without_invitations": "Salva senza inviti",
|
||||
"rsvp_updated": "Risposta aggiornata",
|
||||
"rsvp_error": "Impossibile aggiornare la risposta",
|
||||
"event_duplicated": "Evento duplicato",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "まもなく開始",
|
||||
"alert_in_minutes": "{count}分後",
|
||||
"invitation_sent": "招待を送信しました",
|
||||
"invitations_denied_title": "招待状を送信できません",
|
||||
"invitations_denied": "サーバーが招待状の送信を拒否しました: {reason}。招待状を送らずに予定を保存しますか?",
|
||||
"save_without_invitations": "招待状なしで保存",
|
||||
"rsvp_updated": "回答を更新しました",
|
||||
"rsvp_error": "回答の更新に失敗しました",
|
||||
"event_duplicated": "予定を複製しました",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "지금 시작해요",
|
||||
"alert_in_minutes": "{count}분 후 시작",
|
||||
"invitation_sent": "초대장이 전송되었어요",
|
||||
"invitations_denied_title": "초대장을 보낼 수 없어요",
|
||||
"invitations_denied": "서버가 초대장 발송을 거부했어요: {reason}. 초대장 없이 일정을 저장할까요?",
|
||||
"save_without_invitations": "초대장 없이 저장",
|
||||
"rsvp_updated": "응답이 업데이트되었어요",
|
||||
"rsvp_error": "응답을 업데이트하지 못했어요",
|
||||
"event_duplicated": "일정이 복제되었어요",
|
||||
|
||||
@@ -3008,6 +3008,9 @@
|
||||
"alert_now": "Sākas tagad",
|
||||
"alert_in_minutes": "Pēc {count} min",
|
||||
"invitation_sent": "Uzaicinājumi nosūtīti",
|
||||
"invitations_denied_title": "Ielūgumus nevar nosūtīt",
|
||||
"invitations_denied": "Serveris atteicās nosūtīt ielūgumus: {reason}. Saglabāt notikumu, tos nenosūtot?",
|
||||
"save_without_invitations": "Saglabāt bez ielūgumiem",
|
||||
"rsvp_updated": "Atbilde atjaunināta",
|
||||
"rsvp_error": "Neizdevās atjaunināt atbildi",
|
||||
"event_duplicated": "Pasākums dublēts",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Одооноос эхлэн",
|
||||
"alert_in_minutes": "{count} мин",
|
||||
"invitation_sent": "Урилга илгээсэн",
|
||||
"invitations_denied_title": "Урилга илгээх боломжгүй",
|
||||
"invitations_denied": "Сервер урилга илгээхээс татгалзлаа: {reason}. Урилга илгээхгүйгээр үйл явдлыг хадгалах уу?",
|
||||
"save_without_invitations": "Урилгагүйгээр хадгалах",
|
||||
"rsvp_updated": "Хариулт шинэчлэгдсэн",
|
||||
"rsvp_error": "Хариултыг шинэчилж чадсангүй",
|
||||
"event_duplicated": "Үйл явдал давхардсан",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Starter nå",
|
||||
"alert_in_minutes": "Om {count} min",
|
||||
"invitation_sent": "Invitasjonene er sendt",
|
||||
"invitations_denied_title": "Invitasjonene kan ikke sendes",
|
||||
"invitations_denied": "Serveren nektet å sende invitasjonene: {reason}. Lagre hendelsen uten å sende dem?",
|
||||
"save_without_invitations": "Lagre uten invitasjoner",
|
||||
"rsvp_updated": "Svaret er oppdatert",
|
||||
"rsvp_error": "Kunne ikke oppdatere svaret",
|
||||
"event_duplicated": "Hendelsen er duplisert",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "Begint nu",
|
||||
"alert_in_minutes": "Over {count} min",
|
||||
"invitation_sent": "Uitnodigingen verzonden",
|
||||
"invitations_denied_title": "Uitnodigingen kunnen niet worden verzonden",
|
||||
"invitations_denied": "De server weigerde de uitnodigingen te verzenden: {reason}. Het evenement opslaan zonder ze te verzenden?",
|
||||
"save_without_invitations": "Opslaan zonder uitnodigingen",
|
||||
"rsvp_updated": "Reactie bijgewerkt",
|
||||
"rsvp_error": "Reactie kon niet worden bijgewerkt",
|
||||
"event_duplicated": "Evenement gedupliceerd",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "Zaczyna się teraz",
|
||||
"alert_in_minutes": "Za {count} min",
|
||||
"invitation_sent": "Zaproszenia wysłano",
|
||||
"invitations_denied_title": "Nie można wysłać zaproszeń",
|
||||
"invitations_denied": "Serwer odmówił wysłania zaproszeń: {reason}. Zapisać wydarzenie bez ich wysyłania?",
|
||||
"save_without_invitations": "Zapisz bez zaproszeń",
|
||||
"rsvp_updated": "Odpowiedź zaktualizowano",
|
||||
"rsvp_error": "Nie udało się zaktualizować odpowiedzi",
|
||||
"event_duplicated": "Wydarzenie zduplikowano",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Começa agora",
|
||||
"alert_in_minutes": "Em {count} min",
|
||||
"invitation_sent": "Convites enviados",
|
||||
"invitations_denied_title": "Não é possível enviar os convites",
|
||||
"invitations_denied": "O servidor recusou enviar os convites: {reason}. Guardar o evento sem os enviar?",
|
||||
"save_without_invitations": "Guardar sem convites",
|
||||
"rsvp_updated": "Resposta atualizada",
|
||||
"rsvp_error": "Falha ao atualizar resposta",
|
||||
"event_duplicated": "Evento duplicado",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Începând de acum",
|
||||
"alert_in_minutes": "În {count} min",
|
||||
"invitation_sent": "Invitații trimise",
|
||||
"invitations_denied_title": "Invitațiile nu pot fi trimise",
|
||||
"invitations_denied": "Serverul a refuzat trimiterea invitațiilor: {reason}. Salvezi evenimentul fără a le trimite?",
|
||||
"save_without_invitations": "Salvează fără invitații",
|
||||
"rsvp_updated": "Răspuns actualizat",
|
||||
"rsvp_error": "Nu s-a putut actualiza răspunsul",
|
||||
"event_duplicated": "Eveniment duplicat",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "Начинается сейчас",
|
||||
"alert_in_minutes": "Через {count} мин",
|
||||
"invitation_sent": "Приглашения отправлены",
|
||||
"invitations_denied_title": "Не удаётся отправить приглашения",
|
||||
"invitations_denied": "Сервер отказался отправить приглашения: {reason}. Сохранить событие без их отправки?",
|
||||
"save_without_invitations": "Сохранить без приглашений",
|
||||
"rsvp_updated": "Ответ обновлён",
|
||||
"rsvp_error": "Не удалось обновить ответ",
|
||||
"event_duplicated": "Событие дублировано",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Začína teraz",
|
||||
"alert_in_minutes": "Za {count} min",
|
||||
"invitation_sent": "Pozvánky boli odoslané",
|
||||
"invitations_denied_title": "Pozvánky nie je možné odoslať",
|
||||
"invitations_denied": "Server odmietol odoslať pozvánky: {reason}. Uložiť udalosť bez ich odoslania?",
|
||||
"save_without_invitations": "Uložiť bez pozvánok",
|
||||
"rsvp_updated": "Odpoveď bola aktualizovaná",
|
||||
"rsvp_error": "Aktualizácia odpovede zlyhala",
|
||||
"event_duplicated": "Udalosť bola duplikovaná",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "Şimdi başlıyor",
|
||||
"alert_in_minutes": "{count} dk içinde",
|
||||
"invitation_sent": "Davetler gönderildi",
|
||||
"invitations_denied_title": "Davetler gönderilemiyor",
|
||||
"invitations_denied": "Sunucu davetleri göndermeyi reddetti: {reason}. Etkinlik davet gönderilmeden kaydedilsin mi?",
|
||||
"save_without_invitations": "Davetsiz kaydet",
|
||||
"rsvp_updated": "Yanıt güncellendi",
|
||||
"rsvp_error": "Yanıt güncellenemedi",
|
||||
"event_duplicated": "Etkinlik çoğaltıldı",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "Починаючи зараз",
|
||||
"alert_in_minutes": "Через {count} хв",
|
||||
"invitation_sent": "Запрошення надіслано",
|
||||
"invitations_denied_title": "Не вдається надіслати запрошення",
|
||||
"invitations_denied": "Сервер відмовився надіслати запрошення: {reason}. Зберегти подію без їх надсилання?",
|
||||
"save_without_invitations": "Зберегти без запрошень",
|
||||
"rsvp_updated": "Відповідь оновлено",
|
||||
"rsvp_error": "Не вдалося оновити відповідь",
|
||||
"event_duplicated": "Подія дублюється",
|
||||
|
||||
@@ -3035,6 +3035,9 @@
|
||||
"alert_now": "現在開始",
|
||||
"alert_in_minutes": "{count} 分鐘後",
|
||||
"invitation_sent": "已傳送邀請",
|
||||
"invitations_denied_title": "無法傳送邀請",
|
||||
"invitations_denied": "伺服器拒絕傳送邀請:{reason}。是否不傳送邀請直接儲存活動?",
|
||||
"save_without_invitations": "不傳送邀請並儲存",
|
||||
"rsvp_updated": "回覆已更新",
|
||||
"rsvp_error": "無法更新回覆",
|
||||
"event_duplicated": "已建立活動副本",
|
||||
|
||||
@@ -3009,6 +3009,9 @@
|
||||
"alert_now": "现在开始",
|
||||
"alert_in_minutes": "{count} 分钟后",
|
||||
"invitation_sent": "已发送邀请",
|
||||
"invitations_denied_title": "无法发送邀请",
|
||||
"invitations_denied": "服务器拒绝发送邀请:{reason}。是否不发送邀请直接保存活动?",
|
||||
"save_without_invitations": "不发送邀请并保存",
|
||||
"rsvp_updated": "回复已更新",
|
||||
"rsvp_error": "无法更新回复",
|
||||
"event_duplicated": "活动已复制",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { displayNow } from '@/lib/timezone';
|
||||
import { parseDuration } from '@/components/calendar/event-card';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { SchedulingDeniedError } from '@/lib/jmap/scheduling-error';
|
||||
import {
|
||||
baseEventStoreId,
|
||||
buildFallbackExcludePatch,
|
||||
@@ -711,6 +712,8 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
} catch (error) {
|
||||
debug.error('Failed to create event:', error);
|
||||
set({ error: 'Failed to create event' });
|
||||
// The caller can offer to save it without the invitations.
|
||||
if (error instanceof SchedulingDeniedError) throw error;
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user