mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: offer to remove a cancelled meeting from the calendar in the cancellation mail #1425
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'] });
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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": "ملغى",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "لغو شده",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "בוטל",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "キャンセル済み",
|
||||
|
||||
@@ -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": "취소됨",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Цуцлагдсан",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Отменено",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "สถานะ: ยกเลิกแล้ว",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Скасовано",
|
||||
|
||||
@@ -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": "已取消",
|
||||
|
||||
@@ -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": "已取消",
|
||||
|
||||
Reference in New Issue
Block a user