feat: offer to remove a cancelled meeting from the calendar in the cancellation mail #1425

This commit is contained in:
Linus Rath
2026-10-29 20:24:27 +01:00
parent 0b4e501142
commit 357b6af3c9
33 changed files with 490 additions and 17 deletions
@@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => {
getAccountId: vi.fn(() => 'mail-account'),
getCalendarsAccountId: vi.fn(() => 'calendar-account'),
getCalendarEvent: vi.fn(),
queryCalendarEvents: vi.fn(async () => []),
queryCalendarEvents: vi.fn(async (): Promise<unknown[]> => []),
};
const sourceClientMock = {
@@ -45,6 +45,7 @@ const mocks = vi.hoisted(() => {
importEvents: vi.fn(),
rsvpEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
events: [] as Array<Record<string, unknown>>,
setSelectedDate: vi.fn(),
};
@@ -186,6 +187,7 @@ describe('CalendarInvitationBanner', () => {
mocks.calendarState.importEvents.mockReset();
mocks.calendarState.rsvpEvent.mockReset();
mocks.calendarState.updateEvent.mockReset();
mocks.calendarState.deleteEvent.mockReset();
mocks.calendarState.setSelectedDate.mockReset();
mocks.calendarState.events = [];
mocks.sourceClientMock.parseCalendarEvents.mockReset();
@@ -1094,4 +1096,99 @@ describe('CalendarInvitationBanner', () => {
});
});
});
describe('cancellations (#1425)', () => {
const ORGANIZER = {
'@type': 'Participant', email: 'boss@example.com', calendarAddress: 'mailto:boss@example.com',
roles: { owner: true }, participationStatus: 'accepted',
};
const ME = {
'@type': 'Participant', email: 'user@example.com', calendarAddress: 'mailto:user@example.com',
roles: { attendee: true }, participationStatus: 'accepted',
};
const cancelEmail = (overrides: Partial<Email> = {}) => makeEmail({
id: 'email-cancel',
from: [{ email: 'boss@example.com', name: 'Boss' }],
attachments: [{ partId: '1', blobId: 'blob-c', size: 256, type: 'text/calendar; method=CANCEL', name: 'cancel.ics' }],
...overrides,
});
const parsedCancel = (overrides: Record<string, unknown> = {}) => ({
uid: 'uid-c', title: 'Planning', start: '2026-11-02T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H',
status: 'cancelled', sequence: 2, organizerCalendarAddress: 'mailto:boss@example.com',
participants: { organizer: ORGANIZER, me: ME },
...overrides,
});
const storedEvent = (overrides: Record<string, unknown> = {}) => ({
id: 'ev-c', uid: 'uid-c', title: 'Planning', start: '2026-11-02T10:00:00', timeZone: 'Europe/Berlin',
duration: 'PT1H', sequence: 2, status: 'cancelled', recurrenceId: null, recurrenceRules: null,
recurrenceOverrides: null, participants: { organizer: ORGANIZER, me: ME },
...overrides,
});
it('removes the cancelled meeting from the calendar without telling anyone', async () => {
mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel()]);
mocks.clientMock.queryCalendarEvents.mockResolvedValue([storedEvent()]);
mocks.calendarState.events = [storedEvent()];
mocks.calendarState.deleteEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={cancelEmail()} />);
fireEvent.click(await screen.findByText('remove_from_calendar'));
await waitFor(() => expect(screen.getByText('removed_from_calendar')).toBeInTheDocument());
expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'uid-c' });
expect(mocks.calendarState.deleteEvent).toHaveBeenCalledWith(mocks.clientMock, 'ev-c', false);
expect(mocks.calendarState.events).toEqual([]);
expect(screen.queryByText('remove_from_calendar')).not.toBeInTheDocument();
});
it('takes only the cancelled occurrence out of a series', async () => {
const series = storedEvent({ recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], status: 'confirmed' });
mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel({
start: '2026-11-09T10:00:00', recurrenceId: '2026-11-09T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin',
})]);
mocks.clientMock.queryCalendarEvents.mockResolvedValue([series]);
mocks.calendarState.events = [series];
mocks.calendarState.updateEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={cancelEmail()} />);
fireEvent.click(await screen.findByText('remove_from_calendar'));
await waitFor(() => expect(screen.getByText('removed_from_calendar')).toBeInTheDocument());
expect(mocks.calendarState.updateEvent).toHaveBeenCalledWith(
mocks.clientMock, 'ev-c', { recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } } }, false,
);
expect(mocks.calendarState.deleteEvent).not.toHaveBeenCalled();
});
it('does not offer to remove a meeting the calendar holds a newer version of', async () => {
mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel({ sequence: 1 })]);
mocks.clientMock.queryCalendarEvents.mockResolvedValue([storedEvent({ sequence: 3, status: 'confirmed' })]);
render(<CalendarInvitationBanner email={cancelEmail()} />);
expect(await screen.findByText('cancel_outdated')).toBeInTheDocument();
expect(screen.queryByText('remove_from_calendar')).not.toBeInTheDocument();
});
it('does not offer it for an unverified cancellation from someone other than the organizer', async () => {
mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel()]);
mocks.clientMock.queryCalendarEvents.mockResolvedValue([storedEvent()]);
render(<CalendarInvitationBanner email={cancelEmail({ from: [{ email: 'mallory@evil.example', name: 'Mallory' }] })} />);
await screen.findByText(/could not be verified/);
await waitFor(() => expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'uid-c' }));
expect(screen.queryByText('remove_from_calendar')).not.toBeInTheDocument();
});
it('does not offer it when the meeting is not in the calendar', async () => {
mocks.clientMock.parseCalendarEvents.mockResolvedValue([parsedCancel()]);
mocks.clientMock.queryCalendarEvents.mockResolvedValue([]);
render(<CalendarInvitationBanner email={cancelEmail()} />);
await screen.findByRole('heading', { name: 'Planning' });
await waitFor(() => expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'uid-c' }));
expect(screen.queryByText('remove_from_calendar')).not.toBeInTheDocument();
});
});
});
+81 -16
View File
@@ -41,7 +41,7 @@ import {
type InvitationTrustAssessment,
} from '@/lib/calendar-invitation';
import { participantsAskedAgain } from '@/lib/calendar-participants';
import { isValidTimeZone, moveLocalDateTime } from '@/lib/timezone';
import { planCancellationRemoval, sameRecurrenceId, type CancellationRemoval } from '@/lib/calendar-cancellation';
import { cn } from '@/lib/utils';
import { getEventStartDate } from '@/lib/calendar-utils';
import { sanitizeColor } from '@/components/calendar/event-card';
@@ -223,17 +223,6 @@ function getViewActionLabel(
return t('view_in_calendar');
}
/** Whether two events name the same occurrence: their recurrence ids at the same moment, each in its own zone. */
function sameRecurrenceId(a: Partial<CalendarEvent>, b: Partial<CalendarEvent>): boolean {
if (!a.recurrenceId || !b.recurrenceId) return false;
const zoneA = a.recurrenceIdTimeZone ?? a.timeZone ?? null;
const zoneB = b.recurrenceIdTimeZone ?? b.timeZone ?? null;
const bInA = zoneA && zoneB && isValidTimeZone(zoneA) && isValidTimeZone(zoneB)
? moveLocalDateTime(b.recurrenceId.slice(0, 19), zoneB, zoneA)
: b.recurrenceId;
return a.recurrenceId.slice(0, 19) === bInA.slice(0, 19);
}
/**
* The event in the calendar a counter proposal is about. An attendee's
* proposal for one occurrence of a recurring meeting names that occurrence
@@ -439,12 +428,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
}));
const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({
calendars: state.calendars,
supportsCalendar: state.supportsCalendar,
importEvents: state.importEvents,
rsvpEvent: state.rsvpEvent,
updateEvent: state.updateEvent,
deleteEvent: state.deleteEvent,
events: state.events,
setSelectedDate: state.setSelectedDate,
})));
@@ -464,6 +454,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
// Underlying failure shown under the generic parse error, so "wrong account"
// or a network failure is distinguishable from an unparsable file.
const [parseError, setParseError] = useState<string | null>(null);
// What removing a cancelled meeting from the calendar takes, read from the
// events stored with its uid; null until known.
const [cancelRemoval, setCancelRemoval] = useState<CancellationRemoval | null>(null);
const attachment = findCalendarAttachment(email);
@@ -555,12 +548,25 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
};
}, [showCalendarPicker]);
const detectedMethod = parsedEvent && attachment ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown';
const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod;
const isCancellation = method === 'cancel';
useEffect(() => {
setCancelRemoval(null);
if (!client || !supportsCalendar || !isCancellation || !parsedEvent?.uid) return;
let current = true;
client.queryCalendarEvents({ uid: parsedEvent.uid })
.then((stored) => {
if (current) setCancelRemoval(planCancellationRemoval(stored, parsedEvent));
})
.catch(() => { /* no remove action then */ });
return () => { current = false; };
}, [client, supportsCalendar, isCancellation, parsedEvent]);
if (!attachment || !calendarInvitationParsingEnabled) return null;
const detectedMethod = parsedEvent ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown';
const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod;
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel';
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
const showDetails = !isCollapsed;
const allowsRsvp = method === 'request';
@@ -616,6 +622,17 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const proposalFromParticipant = Boolean(
proposalTarget && senderAddress && findParticipantByEmails(proposalTarget, [senderAddress]),
);
// A cancellation that fails authentication, or comes from someone other
// than the organizer without passing it, does not get to remove anything.
const canRemoveCancelled = Boolean(
client
&& supportsCalendar
&& isCancellation
&& !userIsSender
&& trustAssessment?.level !== 'warning'
&& (cancelRemoval?.kind === 'delete' || cancelRemoval?.kind === 'exclude')
);
const cancellationOutdated = isCancellation && cancelRemoval?.kind === 'outdated';
const resolveExistingEventForRsvp = async () => {
if (!client || !existingEvent) {
@@ -811,6 +828,42 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
}
};
const handleRemoveCancelled = async () => {
const cancel = parsedEvent;
if (!client || !cancel?.uid || isProcessing) return;
setActionNotice(null);
setActionError(null);
setIsProcessing(true);
try {
// Planned again on the events as they are now: the calendar may have
// changed since the mail was opened.
const removal = planCancellationRemoval(await client.queryCalendarEvents({ uid: cancel.uid }), cancel);
setCancelRemoval(removal);
// No scheduling messages: the organizer cancelled it, there is no one
// to tell.
if (removal.kind === 'delete') {
for (const id of removal.ids) {
if (!(await deleteEvent(client, id, false))) return;
}
} else if (removal.kind === 'exclude') {
if (!(await updateEvent(client, removal.id, removal.patch, false))) return;
} else {
return;
}
// Occurrences of it in view go as well.
useCalendarStore.setState((s) => ({
events: s.events.filter((event) => event.uid !== cancel.uid
|| (Boolean(cancel.recurrenceId) && !sameRecurrenceId(event, cancel))),
}));
setCancelRemoval({ kind: 'none' });
setActionNotice(t('removed_from_calendar'));
} catch (err) {
setActionError(describeError(err, t('action_failed')));
} finally {
setIsProcessing(false);
}
};
if (state === 'loading') {
return (
<div className="flex items-center gap-3">
@@ -1010,6 +1063,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && (
<div className="space-y-0.5 text-xs text-muted-foreground">
{bannerInfo && <p className="leading-relaxed">{bannerInfo}</p>}
{cancellationOutdated && <p className="leading-relaxed">{t('cancel_outdated')}</p>}
{actorMessage && <p>{actorMessage}</p>}
{actorSummary?.participationComment && (
<p className="italic">{t('actor_note', { comment: actorSummary.participationComment })}</p>
@@ -1165,6 +1219,17 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</>
)}
{canRemoveCancelled && (
<button
onClick={handleRemoveCancelled}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-destructive px-3 py-1.5 rounded-md border border-border hover:border-destructive/30 hover:bg-destructive/10 transition-colors min-h-[36px] disabled:opacity-50"
>
<CalendarX className="w-3.5 h-3.5" />
{t('remove_from_calendar')}
</button>
)}
{canApplyProposal && (
<button
onClick={handleApplyProposal}
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest';
import { planCancellationRemoval } from '../calendar-cancellation';
import type { CalendarEvent } from '@/lib/jmap/types';
function stored(overrides: Partial<CalendarEvent>): CalendarEvent {
return {
id: 'ev-1',
uid: 'meeting@example.com',
title: 'Weekly sync',
start: '2026-11-02T10:00:00',
timeZone: 'Europe/Berlin',
duration: 'PT1H',
sequence: 1,
recurrenceId: null,
recurrenceRules: null,
recurrenceOverrides: null,
...overrides,
} as CalendarEvent;
}
const WEEKLY = [{ '@type': 'RecurrenceRule', frequency: 'weekly' }] as CalendarEvent['recurrenceRules'];
describe('planCancellationRemoval', () => {
it('deletes every stored event of a cancelled meeting, and nothing else', () => {
const events = [
stored({ id: 'ev-1' }),
stored({ id: 'ev-2', recurrenceId: '2026-11-09T10:00:00' }),
stored({ id: 'other', uid: 'other@example.com' }),
];
expect(planCancellationRemoval(events, { uid: 'meeting@example.com', sequence: 1 }))
.toEqual({ kind: 'delete', ids: ['ev-1', 'ev-2'] });
});
it('removes nothing when the calendar does not hold the meeting', () => {
expect(planCancellationRemoval([stored({ uid: 'other@example.com' })], { uid: 'meeting@example.com' }))
.toEqual({ kind: 'none' });
});
it('leaves the meeting when the calendar holds a newer version than the cancellation', () => {
expect(planCancellationRemoval([stored({ sequence: 3 })], { uid: 'meeting@example.com', sequence: 2 }))
.toEqual({ kind: 'outdated' });
});
it('applies a cancellation with the same sequence, as Stalwart leaves it after marking it cancelled', () => {
expect(planCancellationRemoval([stored({ sequence: 2, status: 'cancelled' })], { uid: 'meeting@example.com', sequence: 2 }))
.toEqual({ kind: 'delete', ids: ['ev-1'] });
});
it('excludes one cancelled occurrence on the series instead of deleting it', () => {
const series = stored({ recurrenceRules: WEEKLY, recurrenceOverrides: null });
expect(planCancellationRemoval([series], {
uid: 'meeting@example.com',
recurrenceId: '2026-11-09T10:00:00',
recurrenceIdTimeZone: 'Europe/Berlin',
sequence: 1,
})).toEqual({
kind: 'exclude',
id: 'ev-1',
patch: { recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } } },
});
});
it('names the occurrence in the series time zone and keeps the other overrides', () => {
const series = stored({
recurrenceRules: WEEKLY,
recurrenceOverrides: { '2026-11-16T10:00:00': { title: 'Moved sync' } },
});
// 09:00 UTC is 10:00 in Berlin on 9 November.
expect(planCancellationRemoval([series], {
uid: 'meeting@example.com',
recurrenceId: '2026-11-09T09:00:00',
recurrenceIdTimeZone: 'Etc/UTC',
})).toEqual({
kind: 'exclude',
id: 'ev-1',
patch: { 'recurrenceOverrides/2026-11-09T10:00:00': { excluded: true } },
});
});
it('replaces the override Stalwart wrote for a cancelled occurrence', () => {
const series = stored({
recurrenceRules: WEEKLY,
recurrenceOverrides: { '2026-11-09T10:00:00': { status: 'cancelled', sequence: 1 } },
});
expect(planCancellationRemoval([series], {
uid: 'meeting@example.com',
recurrenceId: '2026-11-09T10:00:00',
recurrenceIdTimeZone: 'Europe/Berlin',
sequence: 1,
})).toEqual({
kind: 'exclude',
id: 'ev-1',
patch: { 'recurrenceOverrides/2026-11-09T10:00:00': { excluded: true } },
});
});
it('leaves an occurrence that was changed after the cancellation was sent', () => {
const series = stored({
recurrenceRules: WEEKLY,
recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'Back on', sequence: 4 } },
});
expect(planCancellationRemoval([series], {
uid: 'meeting@example.com',
recurrenceId: '2026-11-09T10:00:00',
recurrenceIdTimeZone: 'Europe/Berlin',
sequence: 3,
})).toEqual({ kind: 'outdated' });
});
it('removes nothing for an occurrence already taken out of the series', () => {
const series = stored({
recurrenceRules: WEEKLY,
recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } as Partial<CalendarEvent> },
});
expect(planCancellationRemoval([series], {
uid: 'meeting@example.com',
recurrenceId: '2026-11-09T10:00:00',
recurrenceIdTimeZone: 'Europe/Berlin',
})).toEqual({ kind: 'none' });
});
it('does not write the whole overrides map when the series was read without it', () => {
const series = stored({ recurrenceRules: WEEKLY, recurrenceOverrides: undefined });
expect(planCancellationRemoval([series], {
uid: 'meeting@example.com',
recurrenceId: '2026-11-09T10:00:00',
recurrenceIdTimeZone: 'Europe/Berlin',
})).toEqual({ kind: 'none' });
});
it('deletes an occurrence the calendar holds on its own', () => {
const occurrence = stored({ id: 'occ', recurrenceId: '2026-11-09T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin' });
expect(planCancellationRemoval([occurrence], {
uid: 'meeting@example.com',
recurrenceId: '2026-11-09T10:00:00',
recurrenceIdTimeZone: 'Europe/Berlin',
sequence: 1,
})).toEqual({ kind: 'delete', ids: ['occ'] });
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { CalendarEvent } from '@/lib/jmap/types';
import { buildFallbackExcludePatch } from '@/lib/recurrence-instances';
import { isValidTimeZone, moveLocalDateTime } from '@/lib/timezone';
/**
* `b`'s recurrence id as a local date-time in the zone of `a`'s, each read in
* its own zone (`recurrenceIdTimeZone`, else the event's `timeZone`). Without
* a usable zone on either side it is taken as it is.
*/
function recurrenceIdInZoneOf(a: Partial<CalendarEvent>, b: Partial<CalendarEvent>): string | null {
if (!b.recurrenceId) return null;
const zoneA = a.recurrenceIdTimeZone ?? a.timeZone ?? null;
const zoneB = b.recurrenceIdTimeZone ?? b.timeZone ?? null;
const local = b.recurrenceId.slice(0, 19);
return zoneA && zoneB && isValidTimeZone(zoneA) && isValidTimeZone(zoneB)
? moveLocalDateTime(local, zoneB, zoneA).slice(0, 19)
: local;
}
/** Whether two events name the same occurrence: their recurrence ids at the same moment, each in its own zone. */
export function sameRecurrenceId(a: Partial<CalendarEvent>, b: Partial<CalendarEvent>): boolean {
if (!a.recurrenceId || !b.recurrenceId) return false;
return a.recurrenceId.slice(0, 19) === recurrenceIdInZoneOf(a, b);
}
/**
* What removing a cancelled meeting from the calendar takes:
* - `delete`: destroy these stored events (the series, or an occurrence
* stored on its own).
* - `exclude`: write `patch` on the series `id`, taking one occurrence out.
* - `outdated`: the calendar holds a newer version than the cancellation.
* - `none`: nothing in the calendar to remove.
*/
export type CancellationRemoval =
| { kind: 'delete'; ids: string[] }
| { kind: 'exclude'; id: string; patch: Partial<CalendarEvent> }
| { kind: 'outdated' }
| { kind: 'none' };
/**
* How to remove what an iTIP CANCEL (RFC 5546 3.2.5) names from `stored`,
* the events stored with its uid. Stalwart applies a cancellation itself but
* only marks the event, or the occurrence, STATUS:CANCELLED and keeps it
* (#1425). A cancellation without a recurrence id is for the whole meeting;
* one with a recurrence id is for that occurrence only. A cancellation with
* a lower SEQUENCE than the event it names is older than the event and is
* not applied, as Stalwart does not apply it either.
*/
export function planCancellationRemoval(
stored: CalendarEvent[],
cancel: Partial<CalendarEvent>,
): CancellationRemoval {
if (!cancel.uid) return { kind: 'none' };
const sameUid = stored.filter((event) => event.uid === cancel.uid);
const cancelSequence = cancel.sequence ?? 0;
const isNewer = (sequence: number | null | undefined) => (sequence ?? 0) > cancelSequence;
if (!cancel.recurrenceId) {
if (sameUid.length === 0) return { kind: 'none' };
if (sameUid.some((event) => isNewer(event.sequence))) return { kind: 'outdated' };
return { kind: 'delete', ids: sameUid.map((event) => event.id) };
}
const ownEvent = sameUid.find((event) => sameRecurrenceId(event, cancel));
if (ownEvent) {
return isNewer(ownEvent.sequence) ? { kind: 'outdated' } : { kind: 'delete', ids: [ownEvent.id] };
}
const series = sameUid.find((event) => !event.recurrenceId && (event.recurrenceRules?.length ?? 0) > 0);
// Overrides not read: a whole-map write would drop the ones there are.
if (!series || series.recurrenceOverrides === undefined) return { kind: 'none' };
const key = recurrenceIdInZoneOf(series, cancel);
if (!key) return { kind: 'none' };
const override = series.recurrenceOverrides?.[key] as (Partial<CalendarEvent> & { excluded?: boolean }) | undefined;
if (override?.excluded) return { kind: 'none' };
if (override && isNewer(override.sequence)) return { kind: 'outdated' };
const patch = buildFallbackExcludePatch({
id: series.id,
start: '',
recurrenceId: key,
recurrenceOverrides: series.recurrenceOverrides,
});
return patch ? { kind: 'exclude', id: series.id, patch } : { kind: 'none' };
}
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "موجود بالفعل في تقويمك",
"request_info": "تمت دعوتك إلى هذا الحدث. أجب لإعلام المنظّم بتوفرك.",
"cancel_info": "ألغى المنظّم هذا الحدث.",
"remove_from_calendar": "إزالة من التقويم",
"removed_from_calendar": "أُزيل من تقويمك.",
"cancel_outdated": "يحتوي تقويمك على نسخة من هذا الحدث أحدث من هذا الإلغاء.",
"event_updated": "تحديث رقم {sequence}",
"event_status_tentative": "مبدئي",
"event_status_cancelled": "ملغى",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "Ja és al vostre calendari",
"request_info": "Heu estat convidat a aquest esdeveniment. Responeu per informar l'organitzador de la vostra disponibilitat.",
"cancel_info": "L'organitzador ha cancel·lat aquest esdeveniment.",
"remove_from_calendar": "Treu del calendari",
"removed_from_calendar": "S'ha tret del teu calendari.",
"cancel_outdated": "El teu calendari té una versió d'aquest esdeveniment més recent que aquesta cancel·lació.",
"event_updated": "Actualització núm. {sequence}",
"event_status_tentative": "Provisional",
"event_status_cancelled": "Cancel·lat",
+3
View File
@@ -686,6 +686,9 @@
"already_in_calendar": "Již je ve vašem kalendáři",
"request_info": "Byli jste pozváni na tuto událost. Odpovězte, abyste informovali organizátora o své dostupnosti.",
"cancel_info": "Organizátor tuto událost zrušil.",
"remove_from_calendar": "Odebrat z kalendáře",
"removed_from_calendar": "Odebráno z vašeho kalendáře.",
"cancel_outdated": "Váš kalendář obsahuje novější verzi této události než toto zrušení.",
"event_updated": "Aktualizace #{sequence}",
"event_status_tentative": "Nezávazně",
"event_status_cancelled": "Zrušeno",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "Allerede i din kalender",
"request_info": "Du er blevet inviteret til denne begivenhed. Svar for at lade arrangøren kende din tilgængelighed.",
"cancel_info": "Arrangøren har aflyst denne begivenhed.",
"remove_from_calendar": "Fjern fra kalender",
"removed_from_calendar": "Fjernet fra din kalender.",
"cancel_outdated": "Din kalender har en nyere version af denne begivenhed end denne aflysning.",
"event_updated": "Opdatering #{sequence}",
"event_status_tentative": "Foreløbig",
"event_status_cancelled": "Aflyst",
+3
View File
@@ -684,6 +684,9 @@
"already_in_calendar": "Bereits in Ihrem Kalender",
"request_info": "Sie wurden zu diesem Termin eingeladen. Antworten Sie, um dem Organisator Ihre Verfügbarkeit mitzuteilen.",
"cancel_info": "Der Organisator hat diesen Termin abgesagt.",
"remove_from_calendar": "Aus Kalender entfernen",
"removed_from_calendar": "Aus Ihrem Kalender entfernt.",
"cancel_outdated": "Ihr Kalender enthält eine neuere Version dieses Termins als diese Absage.",
"event_updated": "Aktualisierung #{sequence}",
"event_status_tentative": "Vorläufig",
"event_status_cancelled": "Abgesagt",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "Already in your calendar",
"request_info": "You've been invited to this event. Respond to let the organizer know your availability.",
"cancel_info": "The organizer has cancelled this event.",
"remove_from_calendar": "Remove from calendar",
"removed_from_calendar": "Removed from your calendar.",
"cancel_outdated": "Your calendar has a newer version of this event than this cancellation.",
"event_updated": "Update #{sequence}",
"event_status_tentative": "Tentative",
"event_status_cancelled": "Cancelled",
+3
View File
@@ -684,6 +684,9 @@
"already_in_calendar": "Ya está en tu calendario",
"request_info": "Has sido invitado a este evento. Responde para informar al organizador de tu disponibilidad.",
"cancel_info": "El organizador ha cancelado este evento.",
"remove_from_calendar": "Quitar del calendario",
"removed_from_calendar": "Quitado de tu calendario.",
"cancel_outdated": "Tu calendario tiene una versión de este evento más reciente que esta cancelación.",
"event_updated": "Actualización #{sequence}",
"event_status_tentative": "Provisional",
"event_status_cancelled": "Cancelado",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "Juba sinu kalendris",
"request_info": "Sind on sellele sündmusele kutsutud. Vasta, et anda korraldajale teada oma saadavusest.",
"cancel_info": "Korraldaja on selle sündmuse tühistanud.",
"remove_from_calendar": "Eemalda kalendrist",
"removed_from_calendar": "Eemaldatud sinu kalendrist.",
"cancel_outdated": "Sinu kalendris on sellest sündmusest uuem versioon kui see tühistamine.",
"event_updated": "Uuendus #{sequence}",
"event_status_tentative": "Esialgne",
"event_status_cancelled": "Tühistatud",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "هم‌اکنون در تقویم شماست",
"request_info": "به این رویداد دعوت شده‌اید. پاسخ دهید.",
"cancel_info": "برگزارکننده این رویداد را لغو کرده است.",
"remove_from_calendar": "حذف از تقویم",
"removed_from_calendar": "از تقویم شما حذف شد.",
"cancel_outdated": "تقویم شما نسخه‌ای جدیدتر از این رویداد نسبت به این لغو دارد.",
"event_updated": "به‌روزرسانی #{sequence}",
"event_status_tentative": "احتمالی",
"event_status_cancelled": "لغو شده",
+3
View File
@@ -684,6 +684,9 @@
"already_in_calendar": "Déjà dans votre calendrier",
"request_info": "Vous êtes invité à cet événement. Répondez pour informer l'organisateur de votre disponibilité.",
"cancel_info": "L'organisateur a annulé cet événement.",
"remove_from_calendar": "Retirer du calendrier",
"removed_from_calendar": "Retiré de votre calendrier.",
"cancel_outdated": "Votre calendrier contient une version de cet événement plus récente que cette annulation.",
"event_updated": "Mise à jour #{sequence}",
"event_status_tentative": "Provisoire",
"event_status_cancelled": "Annulé",
+3
View File
@@ -634,6 +634,9 @@
"already_in_calendar": "כבר בלוח השנה שלך",
"request_info": "הוזמנת לאירוע זה. השב כדי ליידע את המארגן על זמינותך.",
"cancel_info": "המארגן ביטל את האירוע הזה.",
"remove_from_calendar": "הסרה מהיומן",
"removed_from_calendar": "הוסר מהיומן שלך.",
"cancel_outdated": "ביומן שלך יש גרסה חדשה יותר של האירוע הזה מאשר הביטול הזה.",
"event_updated": "עדכון #{sequence}",
"event_status_tentative": "טנטטיבי",
"event_status_cancelled": "בוטל",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "Már a naptáradban van",
"request_info": "Meghívtak erre az eseményre. Válaszolj, hogy a szervező tudja a rendelkezésre állásod.",
"cancel_info": "A szervező lemondta ezt az eseményt.",
"remove_from_calendar": "Eltávolítás a naptárból",
"removed_from_calendar": "Eltávolítva a naptárából.",
"cancel_outdated": "A naptárában ennek az eseménynek újabb verziója van, mint ez a lemondás.",
"event_updated": "Frissítés #{sequence}",
"event_status_tentative": "Előzetes",
"event_status_cancelled": "Lemondva",
+3
View File
@@ -684,6 +684,9 @@
"already_in_calendar": "Già nel tuo calendario",
"request_info": "Sei stato invitato a questo evento. Rispondi per comunicare la tua disponibilità all'organizzatore.",
"cancel_info": "L'organizzatore ha annullato questo evento.",
"remove_from_calendar": "Rimuovi dal calendario",
"removed_from_calendar": "Rimosso dal tuo calendario.",
"cancel_outdated": "Il tuo calendario contiene una versione di questo evento più recente di questo annullamento.",
"event_updated": "Aggiornamento #{sequence}",
"event_status_tentative": "Provvisorio",
"event_status_cancelled": "Annullato",
+3
View File
@@ -684,6 +684,9 @@
"already_in_calendar": "カレンダーに登録済み",
"request_info": "このイベントに招待されました。主催者に参加可否をお知らせください。",
"cancel_info": "主催者がこのイベントをキャンセルしました。",
"remove_from_calendar": "カレンダーから削除",
"removed_from_calendar": "カレンダーから削除しました。",
"cancel_outdated": "カレンダーにはこのキャンセルより新しいバージョンの予定があります。",
"event_updated": "更新 #{sequence}",
"event_status_tentative": "仮",
"event_status_cancelled": "キャンセル済み",
+3
View File
@@ -686,6 +686,9 @@
"already_in_calendar": "이미 내 캘린더에 있어요",
"request_info": "이 일정에 초대받았어요. 참석 가능 여부를 주최자에게 알려주세요.",
"cancel_info": "주최자가 이 일정을 취소했어요.",
"remove_from_calendar": "캘린더에서 제거",
"removed_from_calendar": "캘린더에서 제거했습니다.",
"cancel_outdated": "캘린더에 이 취소보다 최신 버전의 일정이 있습니다.",
"event_updated": "업데이트 #{sequence}",
"event_status_tentative": "미정",
"event_status_cancelled": "취소됨",
+3
View File
@@ -686,6 +686,9 @@
"already_in_calendar": "Jau ir jūsu kalendārā",
"request_info": "Jūs esat uzaicināts uz šo pasākumu. Lūdzu, atbildiet.",
"cancel_info": "Organizators atcēla šo pasākumu.",
"remove_from_calendar": "Noņemt no kalendāra",
"removed_from_calendar": "Noņemts no jūsu kalendāra.",
"cancel_outdated": "Jūsu kalendārā ir jaunāka šī notikuma versija nekā šis atcelšanas paziņojums.",
"event_updated": "Atjauninājums Nr. {sequence}",
"event_status_tentative": "Pagaidām",
"event_status_cancelled": "Atcelts",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "Таны хуанлид аль хэдийн орсон",
"request_info": "Таныг энэ арга хэмжээнд урьсан. Зохион байгуулагчид боломжоо мэдэгдэхийн тулд хариу бичнэ үү.",
"cancel_info": "Зохион байгуулагч энэ арга хэмжээг цуцалсан.",
"remove_from_calendar": "Хуанлиас хасах",
"removed_from_calendar": "Таны хуанлиас хасагдлаа.",
"cancel_outdated": "Таны хуанлид энэ үйл явдлын энэ цуцлалтаас шинэ хувилбар байна.",
"event_updated": "Шинэчлэх #{sequence}",
"event_status_tentative": "Урьдчилсан",
"event_status_cancelled": "Цуцлагдсан",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "Allerede i kalenderen din",
"request_info": "Du er invitert til denne hendelsen. Svar for å gi arrangøren beskjed om du kan delta.",
"cancel_info": "Arrangøren har avlyst denne hendelsen.",
"remove_from_calendar": "Fjern fra kalenderen",
"removed_from_calendar": "Fjernet fra kalenderen din.",
"cancel_outdated": "Kalenderen din har en nyere versjon av denne hendelsen enn denne avlysningen.",
"event_updated": "Oppdatering #{sequence}",
"event_status_tentative": "Foreløpig",
"event_status_cancelled": "Avlyst",
+3
View File
@@ -684,6 +684,9 @@
"already_in_calendar": "Staat al in je agenda",
"request_info": "Je bent uitgenodigd voor dit evenement. Reageer om de organisator te laten weten of je kunt deelnemen.",
"cancel_info": "De organisator heeft dit evenement geannuleerd.",
"remove_from_calendar": "Uit agenda verwijderen",
"removed_from_calendar": "Uit je agenda verwijderd.",
"cancel_outdated": "Je agenda bevat een nieuwere versie van dit evenement dan deze annulering.",
"event_updated": "Update #{sequence}",
"event_status_tentative": "Voorlopig",
"event_status_cancelled": "Geannuleerd",
+3
View File
@@ -686,6 +686,9 @@
"already_in_calendar": "Już w Twoim kalendarzu",
"request_info": "Zostałeś zaproszony na to wydarzenie. Odpowiedz, aby poinformować organizatora o swojej dostępności.",
"cancel_info": "Organizator anulował to wydarzenie.",
"remove_from_calendar": "Usuń z kalendarza",
"removed_from_calendar": "Usunięto z Twojego kalendarza.",
"cancel_outdated": "Twój kalendarz zawiera nowszą wersję tego wydarzenia niż to odwołanie.",
"event_updated": "Aktualizacja #{sequence}",
"event_status_tentative": "Wstępne",
"event_status_cancelled": "Anulowane",
+3
View File
@@ -684,6 +684,9 @@
"already_in_calendar": "Já está no seu calendário",
"request_info": "Você foi convidado para este evento. Responda para informar o organizador sobre sua disponibilidade.",
"cancel_info": "O organizador cancelou este evento.",
"remove_from_calendar": "Remover do calendário",
"removed_from_calendar": "Removido do seu calendário.",
"cancel_outdated": "O seu calendário tem uma versão deste evento mais recente do que este cancelamento.",
"event_updated": "Atualização #{sequence}",
"event_status_tentative": "Provisório",
"event_status_cancelled": "Cancelado",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "Deja în calendarul dvs.",
"request_info": "Ați fost invitat la acest eveniment. Răspundeți pentru a-i comunica organizatorului disponibilitatea dumneavoastră.",
"cancel_info": "Organizatorul a anulat acest eveniment.",
"remove_from_calendar": "Elimină din calendar",
"removed_from_calendar": "Eliminat din calendarul tău.",
"cancel_outdated": "Calendarul tău are o versiune a acestui eveniment mai nouă decât această anulare.",
"event_updated": "Actualizați # {sequence}",
"event_status_tentative": "Provizoriu",
"event_status_cancelled": "Anulat",
+3
View File
@@ -686,6 +686,9 @@
"already_in_calendar": "Уже в вашем календаре",
"request_info": "Вас пригласили на это событие. Ответьте, чтобы организатор знал о вашей доступности.",
"cancel_info": "Организатор отменил это событие.",
"remove_from_calendar": "Удалить из календаря",
"removed_from_calendar": "Удалено из вашего календаря.",
"cancel_outdated": "В вашем календаре более новая версия этого события, чем эта отмена.",
"event_updated": "Обновление №{sequence}",
"event_status_tentative": "Под вопросом",
"event_status_cancelled": "Отменено",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "Už je vo vašom kalendári",
"request_info": "Boli ste pozvaní na túto udalosť. Odpovedzte, aby organizátor vedel o vašej dostupnosti.",
"cancel_info": "Organizátor zrušil túto udalosť.",
"remove_from_calendar": "Odstrániť z kalendára",
"removed_from_calendar": "Odstránené z vášho kalendára.",
"cancel_outdated": "Váš kalendár obsahuje novšiu verziu tejto udalosti než toto zrušenie.",
"event_updated": "Aktualizácia #{sequence}",
"event_status_tentative": "Nezaväzne",
"event_status_cancelled": "Zrušené",
+3
View File
@@ -1646,6 +1646,9 @@
"already_in_calendar": "มีกิจกรรมนี้ในปฏิทินของคุณแล้ว",
"request_info": "คำขอเข้าร่วมกิจกรรม",
"cancel_info": "กิจกรรมนี้ถูกยกเลิกแล้ว",
"remove_from_calendar": "นำออกจากปฏิทิน",
"removed_from_calendar": "นำออกจากปฏิทินของคุณแล้ว",
"cancel_outdated": "ปฏิทินของคุณมีเวอร์ชันของกิจกรรมนี้ที่ใหม่กว่าการยกเลิกนี้",
"event_updated": "การอัปเดตครั้งที่ #{sequence}",
"event_status_tentative": "สถานะ: รอการยืนยัน",
"event_status_cancelled": "สถานะ: ยกเลิกแล้ว",
+3
View File
@@ -686,6 +686,9 @@
"already_in_calendar": "Zaten takviminizde",
"request_info": "Bu etkinliğe davet edildiniz. Müsaitliğinizi bildirmek için yanıt verin.",
"cancel_info": "Organizatör bu etkinliği iptal etti.",
"remove_from_calendar": "Takvimden kaldır",
"removed_from_calendar": "Takviminizden kaldırıldı.",
"cancel_outdated": "Takviminizde bu etkinliğin bu iptalden daha yeni bir sürümü var.",
"event_updated": "Güncelleme #{sequence}",
"event_status_tentative": "Belirsiz",
"event_status_cancelled": "İptal edildi",
+3
View File
@@ -686,6 +686,9 @@
"already_in_calendar": "Уже у вашому календарі",
"request_info": "Вас запросили на цю подію. Дайте відповідь, щоб повідомити організатора про вашу доступність.",
"cancel_info": "Організатор скасував цю подію.",
"remove_from_calendar": "Видалити з календаря",
"removed_from_calendar": "Видалено з вашого календаря.",
"cancel_outdated": "У вашому календарі новіша версія цієї події, ніж це скасування.",
"event_updated": "Оновлення №{sequence}",
"event_status_tentative": "Орієнтовний",
"event_status_cancelled": "Скасовано",
+3
View File
@@ -711,6 +711,9 @@
"already_in_calendar": "已在您的行事曆中",
"request_info": "您已受邀參加此活動。請回覆,讓主辦人知道您是否能參加。",
"cancel_info": "主辦人已取消此活動。",
"remove_from_calendar": "從行事曆移除",
"removed_from_calendar": "已從你的行事曆移除。",
"cancel_outdated": "你的行事曆中此活動的版本比這份取消通知更新。",
"event_updated": "更新 #{sequence}",
"event_status_tentative": "暫定",
"event_status_cancelled": "已取消",
+3
View File
@@ -686,6 +686,9 @@
"already_in_calendar": "已在你的日历中",
"request_info": "您已被邀请参加此活动。回复以让组织者了解您是否参加。",
"cancel_info": "组织者已取消此活动。",
"remove_from_calendar": "从日历中移除",
"removed_from_calendar": "已从你的日历中移除。",
"cancel_outdated": "你的日历中此活动的版本比这份取消通知更新。",
"event_updated": "更新 #{sequence}",
"event_status_tentative": "暂定",
"event_status_cancelled": "已取消",