mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
fix: say that a series invitation repeats, answer the whole series, and name the weeks declined on their own
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CalendarInvitationBanner } from '../calendar-invitation-banner';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const pushMock = vi.fn();
|
||||
const clientMock = {
|
||||
parseCalendarEvents: vi.fn(),
|
||||
getAccountId: vi.fn(() => 'mail-account'),
|
||||
getCalendarsAccountId: vi.fn(() => 'calendar-account'),
|
||||
getCalendarEvent: vi.fn(),
|
||||
queryCalendarEvents: vi.fn(async (): Promise<unknown[]> => []),
|
||||
// Who owns a shared or group account (Principal/get).
|
||||
getAccountOwners: vi.fn(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, id === 'team'
|
||||
? { id, type: 'group', name: 'team@example.com', email: 'team@example.com' }
|
||||
: { id, type: 'individual', name: id + '@example.com', email: id + '@example.com' }]))),
|
||||
};
|
||||
|
||||
const sourceClientMock = {
|
||||
parseCalendarEvents: vi.fn(),
|
||||
fetchBlob: vi.fn(),
|
||||
getAccountId: vi.fn(() => 'source-mail-account'),
|
||||
getCalendarsAccountId: vi.fn(() => 'source-calendar-account'),
|
||||
};
|
||||
|
||||
const authState = {
|
||||
client: clientMock,
|
||||
primaryIdentity: { email: 'user@example.com' },
|
||||
identities: [{ email: 'user@example.com' }] as Array<{ email: string }>,
|
||||
getClientForAccount: vi.fn((id: string) => (id === 'login-b' ? sourceClientMock : undefined)),
|
||||
};
|
||||
|
||||
const emailState = {
|
||||
viewingAccountId: null as string | null,
|
||||
selectedMailbox: 'inbox',
|
||||
mailboxes: [] as Array<Record<string, unknown>>,
|
||||
accountMailboxes: {} as Record<string, Array<Record<string, unknown>>>,
|
||||
};
|
||||
|
||||
const settingsState = {
|
||||
calendarInvitationParsingEnabled: true,
|
||||
};
|
||||
|
||||
const calendarState = {
|
||||
calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }],
|
||||
supportsCalendar: true,
|
||||
importEvents: vi.fn(),
|
||||
rsvpEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
deleteEvent: vi.fn(),
|
||||
events: [] as Array<Record<string, unknown>>,
|
||||
setSelectedDate: vi.fn(),
|
||||
};
|
||||
|
||||
const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => (
|
||||
typeof selector === 'function' ? selector(calendarState) : calendarState
|
||||
)) as {
|
||||
(selector?: (state: typeof calendarState) => unknown): unknown;
|
||||
getState: () => typeof calendarState;
|
||||
setState: (updater: Partial<typeof calendarState> | ((state: typeof calendarState) => Partial<typeof calendarState> | typeof calendarState)) => void;
|
||||
};
|
||||
|
||||
useCalendarStoreMock.getState = () => calendarState;
|
||||
useCalendarStoreMock.setState = (updater) => {
|
||||
const nextState = typeof updater === 'function' ? updater(calendarState) : updater;
|
||||
Object.assign(calendarState, nextState);
|
||||
};
|
||||
|
||||
return {
|
||||
pushMock,
|
||||
clientMock,
|
||||
sourceClientMock,
|
||||
authState,
|
||||
emailState,
|
||||
settingsState,
|
||||
calendarState,
|
||||
useCalendarStoreMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, string>) => {
|
||||
const strings: Record<string, string> = {
|
||||
loading: 'Loading event details…',
|
||||
title: 'Calendar Invitation',
|
||||
organizer: 'Organized by {name}',
|
||||
attendees: 'attendees',
|
||||
add_to_calendar: 'Add to calendar',
|
||||
added: 'Added to calendar',
|
||||
rsvp_sent: 'Response sent',
|
||||
already_in_calendar: 'Already in your calendar',
|
||||
your_response: 'Your response: {status}',
|
||||
response_accepted: 'Accepted',
|
||||
response_needed: 'Needs response',
|
||||
actor_response_info: '{name} responded {status}.',
|
||||
actor_sent_info: 'Sent by {name}.',
|
||||
actor_counter_info: '{name} proposed changes to this event.',
|
||||
actor_refresh_info: '{name} asked for the latest event details.',
|
||||
actor_declined_counter_info: '{name} declined the counter proposal.',
|
||||
actor_note: 'Note: {comment}',
|
||||
actor_unknown: 'Someone',
|
||||
sender_mismatch_unverified_info: 'This invitation was sent from {sender}, while the organizer listed in the calendar data is {organizer}, and the message could not be verified.',
|
||||
attendee_mismatch_unverified_info: 'This response was sent from {sender}, while the attendee answering in the calendar data is {attendee}, and the message could not be verified.',
|
||||
action_failed: 'Could not complete that calendar action.',
|
||||
proposal_applied: 'Proposed changes applied.',
|
||||
proposed_changes: 'Proposed changes',
|
||||
change_title: 'Title',
|
||||
change_time: 'Time',
|
||||
change_location: 'Location',
|
||||
change_description: 'Description',
|
||||
change_empty: 'None',
|
||||
change_from_to: '{before} -> {after}',
|
||||
apply_proposal: 'Apply proposed changes',
|
||||
review_proposal: 'Review proposal',
|
||||
review_request: 'Review request',
|
||||
view_in_calendar: 'View in calendar',
|
||||
organizer_role: 'You organize this event',
|
||||
accept: 'Accept',
|
||||
maybe: 'Maybe',
|
||||
decline: 'Decline',
|
||||
select_calendar: 'Select calendar',
|
||||
participant_limit: 'The server allows at most {max} participants per event.',
|
||||
answered_accepted: 'You accepted on {date}.',
|
||||
answered_declined: 'You declined on {date}.',
|
||||
response_declined: 'Declined',
|
||||
request_info: 'Respond to let the organizer know.',
|
||||
repeats: 'Repeats: {rule}',
|
||||
declined_dates: 'You declined {dates}.',
|
||||
declined_many: 'You declined {count} meetings of this series.',
|
||||
answers_series: 'Your answer is for every meeting in the series.',
|
||||
'recurrence.weekly': 'Weekly',
|
||||
'recurrence.on_days': 'on {days}',
|
||||
};
|
||||
|
||||
let message = strings[key] ?? key;
|
||||
if (values) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
message = message.replace(`{${name}}`, String(value));
|
||||
}
|
||||
}
|
||||
return message;
|
||||
},
|
||||
useLocale: () => 'en-US',
|
||||
useFormatter: () => ({
|
||||
dateTime: (date: Date) => date.toISOString(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: mocks.pushMock }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/email-store', () => ({
|
||||
useEmailStore: (selector: (state: typeof mocks.emailState) => unknown) => selector(mocks.emailState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/settings-store', () => ({
|
||||
useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/calendar-store', () => ({
|
||||
useCalendarStore: mocks.useCalendarStoreMock,
|
||||
}));
|
||||
|
||||
function makeEmail(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
size: 512,
|
||||
receivedAt: '2026-03-16T10:00:00Z',
|
||||
hasAttachment: true,
|
||||
attachments: [
|
||||
{
|
||||
partId: '1',
|
||||
blobId: 'blob-1',
|
||||
size: 256,
|
||||
type: 'text/calendar; method=REQUEST',
|
||||
name: 'invite.ics',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// pim-p2-8 (and pim-23): a weekly invitation where one week was declined said
|
||||
// "Your response: Declined. You declined on Wed, Nov 4." while the calendar
|
||||
// showed the following weeks unanswered, and nothing said it repeats. The
|
||||
// card now reads the series as stored: its own answer, the declined weeks by
|
||||
// date, that it repeats, and that the buttons answer every week.
|
||||
|
||||
const me = { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'needs-action' };
|
||||
const ben = { '@type': 'Participant', email: 'ben@example.com', name: 'Ben', roles: { owner: true, attendee: true }, participationStatus: 'accepted' };
|
||||
const rule = { '@type': 'RecurrenceRule', frequency: 'weekly', byDay: [{ '@type': 'NDay', day: 'tu' }] };
|
||||
const invitation = {
|
||||
uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin',
|
||||
recurrenceRules: [rule], participants: { me, ben },
|
||||
};
|
||||
// The series as Stalwart 1.0 stores it: Nov 3 declined on its own, as a pointer patch.
|
||||
const master = {
|
||||
id: 'series-1', uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M',
|
||||
timeZone: 'Europe/Berlin', recurrenceRules: [rule], participants: { me, ben },
|
||||
recurrenceOverrides: { '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } },
|
||||
};
|
||||
// What the grid holds: the expanded occurrences, the declined one first.
|
||||
const nov3 = {
|
||||
...master, id: 'occ-nov3', recurrenceId: '2026-11-03T16:00:00', recurrenceOverrides: null,
|
||||
participants: { me: { ...me, participationStatus: 'declined' }, ben }, updated: '2026-11-04T09:00:00Z',
|
||||
};
|
||||
const nov10 = { ...master, id: 'occ-nov10', recurrenceId: '2026-11-10T16:00:00', start: '2026-11-10T16:00:00', recurrenceOverrides: null };
|
||||
|
||||
describe('an invitation to a series', () => {
|
||||
beforeEach(() => {
|
||||
mocks.settingsState.calendarInvitationParsingEnabled = true;
|
||||
mocks.clientMock.parseCalendarEvents.mockReset();
|
||||
mocks.clientMock.queryCalendarEvents.mockReset();
|
||||
mocks.calendarState.rsvpEvent.mockReset();
|
||||
mocks.calendarState.events = [nov3, nov10] as never;
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]);
|
||||
mocks.clientMock.queryCalendarEvents.mockImplementation(async () => [master]);
|
||||
mocks.authState.identities = [{ email: 'user@example.com' }];
|
||||
});
|
||||
|
||||
it("shows the series' own answer and the declined week by its date", async () => {
|
||||
render(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
expect(await screen.findByText('You declined Nov 3.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your response: Declined')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^You declined on/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Your response: Needs response')).toBeInTheDocument();
|
||||
expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'weekly-1' });
|
||||
});
|
||||
|
||||
it('says that it repeats, and that an answer is for every week', async () => {
|
||||
render(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
expect(await screen.findByTestId('invitation-repeats')).toHaveTextContent('Repeats: Weekly on Tue');
|
||||
expect(await screen.findByText('Your answer is for every meeting in the series.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('answers the series, not the week the calendar held first', async () => {
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
|
||||
render(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
await screen.findByText('You declined Nov 3.');
|
||||
fireEvent.click(screen.getByText('Accept'));
|
||||
await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalled());
|
||||
expect(mocks.calendarState.rsvpEvent.mock.calls[0][1]).toBe('series-1');
|
||||
expect(mocks.calendarState.rsvpEvent.mock.calls[0][3]).toBe('accepted');
|
||||
});
|
||||
|
||||
it('leaves a one-off invitation as it was', async () => {
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ ...invitation, uid: 'once', recurrenceRules: null }]);
|
||||
render(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
await screen.findByText('Weekly sync with Ben');
|
||||
expect(screen.queryByTestId('invitation-repeats')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('invitation-answers-series')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Repeat,
|
||||
} from '@/components/icons';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { InvitationPrintSync } from '@/lib/print/invitation-print';
|
||||
@@ -39,6 +40,8 @@ import {
|
||||
invitationAnsweredAt,
|
||||
invitationAnsweredKey,
|
||||
findParticipantByEmails,
|
||||
isSeriesInvitation,
|
||||
seriesAnswers,
|
||||
extractMethodFromRawIcs,
|
||||
type EventSummary,
|
||||
type InvitationMethod,
|
||||
@@ -55,6 +58,7 @@ import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message';
|
||||
import { useAccountOwner } from '@/components/calendar/answer-bars';
|
||||
import { useCollectionNames } from "@/hooks/use-collection-names";
|
||||
import { resolveTimeZone } from '@/lib/timezone';
|
||||
import { buildRecurrenceSummary } from '@/components/calendar/recurrence-editor';
|
||||
|
||||
interface InvitationChangeItem {
|
||||
label: string;
|
||||
@@ -384,6 +388,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const shownName = useCollectionNames();
|
||||
const t = useTranslations('email_viewer.calendar_invitation');
|
||||
const tEvent = useTranslations('ux_pim_event');
|
||||
const tCalendar = useTranslations('calendar');
|
||||
const tSeries = useTranslations('ux_p2_pim_calx');
|
||||
const describeError = useCalendarErrorMessage();
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -479,6 +485,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
// 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);
|
||||
// An invitation to a series is answered as a series (pim-p2-8): the series as
|
||||
// stored, with the occurrences answered on their own. The calendar holds the
|
||||
// occurrences, and the first of them was read as the answer.
|
||||
const [seriesMaster, setSeriesMaster] = useState<CalendarEvent | null>(null);
|
||||
|
||||
const attachment = findCalendarAttachment(email);
|
||||
|
||||
@@ -577,6 +587,20 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod;
|
||||
const isCancellation = method === 'cancel';
|
||||
|
||||
const seriesUid = isSeriesInvitation(parsedEvent) ? parsedEvent?.uid ?? null : null;
|
||||
useEffect(() => {
|
||||
setSeriesMaster(null);
|
||||
if (!client || !supportsCalendar || !seriesUid || sharedMailAccount) return;
|
||||
let current = true;
|
||||
client.queryCalendarEvents({ uid: seriesUid })
|
||||
.then((stored) => {
|
||||
const master = stored.find((e) => e.uid === seriesUid && !e.recurrenceId && !e.isShared) ?? null;
|
||||
if (current) setSeriesMaster(master);
|
||||
})
|
||||
.catch(() => { /* the occurrence the calendar holds stands in */ });
|
||||
return () => { current = false; };
|
||||
}, [client, supportsCalendar, seriesUid, sharedMailAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
setCancelRemoval(null);
|
||||
if (!client || !supportsCalendar || !isCancellation || !parsedEvent?.uid) return;
|
||||
@@ -634,9 +658,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
// answering there would answer for them (or, on Stalwart 0.16, for
|
||||
// whichever address it picks; the duplicate attendees and wrong senders of
|
||||
// #634).
|
||||
const existingEvent = parsedEvent?.uid
|
||||
const storedCopy = parsedEvent?.uid
|
||||
? storeEvents.find((e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared))
|
||||
: null;
|
||||
const existingEvent = seriesMaster ?? storedCopy;
|
||||
const isSeries = isSeriesInvitation(parsedEvent);
|
||||
const seriesRule = isSeries && parsedEvent?.recurrenceRules?.[0]
|
||||
? buildRecurrenceSummary(parsedEvent.recurrenceRules[0], tCalendar, locale)
|
||||
: null;
|
||||
|
||||
const currentUserParticipant = existingEvent
|
||||
? findParticipantByEmails(existingEvent, actingEmails)
|
||||
@@ -679,6 +708,21 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
})
|
||||
: null;
|
||||
const bannerInfo = answeredInfo ?? getBannerInfo(t, method, userIsOrganizer, supportsCalendar);
|
||||
// "You declined Nov 3": the weeks answered no on their own, by their date.
|
||||
const declinedOccurrences = seriesMaster && method === 'request' && !rsvpStatus
|
||||
? seriesAnswers(seriesMaster, actingEmails).declined
|
||||
: [];
|
||||
const occurrenceDate = (recurrenceId: string) => {
|
||||
const at = new Date(`${recurrenceId.slice(0, 19)}Z`);
|
||||
return isNaN(at.getTime()) ? recurrenceId : new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(at);
|
||||
};
|
||||
const declinedInfo = declinedOccurrences.length === 0
|
||||
? null
|
||||
: declinedOccurrences.length > 3
|
||||
? tSeries('declined_many', { count: declinedOccurrences.length })
|
||||
: tSeries('declined_dates', {
|
||||
dates: new Intl.ListFormat(locale, { type: 'conjunction' }).format(declinedOccurrences.map(occurrenceDate)),
|
||||
});
|
||||
const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method, identityEmails) : null;
|
||||
const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null;
|
||||
const participationLabel = getParticipationLabel(t, currentRsvp);
|
||||
@@ -1043,6 +1087,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{seriesRule && (
|
||||
<span className="flex items-center gap-1.5 min-w-0" data-testid="invitation-repeats">
|
||||
<Repeat className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{tSeries('repeats', { rule: seriesRule })}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.location && (
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
@@ -1112,9 +1162,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
)}
|
||||
|
||||
{/* Info / actor messages */}
|
||||
{showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && (
|
||||
{showDetails && (bannerInfo || declinedInfo || actorMessage || actorSummary?.participationComment) && (
|
||||
<div className="space-y-0.5 text-xs text-muted-foreground">
|
||||
{bannerInfo && <p className="leading-relaxed">{bannerInfo}</p>}
|
||||
{declinedInfo && <p className="leading-relaxed" data-testid="invitation-declined-occurrences">{declinedInfo}</p>}
|
||||
{cancellationOutdated && <p className="leading-relaxed">{t('cancel_outdated')}</p>}
|
||||
{actorMessage && <p>{actorMessage}</p>}
|
||||
{actorSummary?.participationComment && (
|
||||
@@ -1206,6 +1257,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t('decline')}
|
||||
</button>
|
||||
{isSeries && (
|
||||
<span className="text-xs text-muted-foreground" data-testid="invitation-answers-series">{tSeries('answers_series')}</span>
|
||||
)}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSeriesInvitation, seriesAnswers } from '../calendar-invitation';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// pim-p2-8: the series' own answer, and the occurrences declined on their own.
|
||||
// Stalwart 1.0 stores an occurrence's answer as a pointer patch, 0.16 as the
|
||||
// whole participants map.
|
||||
|
||||
const me = { '@type': 'Participant', email: 'me@example.org', roles: { attendee: true }, participationStatus: 'accepted' };
|
||||
const ben = { '@type': 'Participant', email: 'ben@example.org', roles: { owner: true }, participationStatus: 'accepted' };
|
||||
|
||||
function series(overrides: Record<string, unknown>): Partial<CalendarEvent> {
|
||||
return {
|
||||
uid: 'w', start: '2026-11-03T16:00:00', recurrenceRules: [{ frequency: 'weekly' }],
|
||||
participants: { me, ben }, recurrenceOverrides: overrides,
|
||||
} as unknown as Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
describe('seriesAnswers', () => {
|
||||
it('reads a pointer patch (Stalwart 1.0)', () => {
|
||||
const result = seriesAnswers(series({
|
||||
'2026-11-17T16:00:00': { 'participants/me/participationStatus': 'declined' },
|
||||
'2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' },
|
||||
'2026-11-10T16:00:00': { title: 'Moved room' },
|
||||
}), ['me@example.org']);
|
||||
expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00', '2026-11-17T16:00:00'] });
|
||||
});
|
||||
|
||||
it('reads a whole participants map (Stalwart 0.16) and skips removed weeks', () => {
|
||||
const result = seriesAnswers(series({
|
||||
'2026-11-03T16:00:00': { participants: { me: { ...me, participationStatus: 'declined' }, ben } },
|
||||
'2026-11-10T16:00:00': { excluded: true },
|
||||
}), ['ME@example.org']);
|
||||
expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00'] });
|
||||
});
|
||||
|
||||
it('lists none when the whole series is declined', () => {
|
||||
const declined = { ...me, participationStatus: 'declined' };
|
||||
const event = { ...series({ '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } }), participants: { me: declined, ben } };
|
||||
expect(seriesAnswers(event as unknown as Partial<CalendarEvent>, ['me@example.org'])).toEqual({ status: 'declined', declined: [] });
|
||||
});
|
||||
|
||||
it('tells a series invitation from one for a single occurrence', () => {
|
||||
expect(isSeriesInvitation(series({}))).toBe(true);
|
||||
expect(isSeriesInvitation({ ...series({}), recurrenceId: '2026-11-03T16:00:00' } as Partial<CalendarEvent>)).toBe(false);
|
||||
expect(isSeriesInvitation({ uid: 'x' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPa
|
||||
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||
import { hasAlignedAuthenticationPass } from '@/lib/email-headers';
|
||||
import { fromWallClock, getTimeZoneOffsetMs, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone';
|
||||
import { applyOverridePatch } from '@/lib/recurrence-instances';
|
||||
|
||||
export type InvitationMethod =
|
||||
| 'publish'
|
||||
@@ -797,6 +798,35 @@ export function invitationAnsweredAt(
|
||||
* user's identities, primary first: an invite may be addressed to an alias
|
||||
* identity rather than the primary address (#1283).
|
||||
*/
|
||||
/**
|
||||
* An invitation to a series is answered as a series: the answer is the series'
|
||||
* own, and occurrences answered no on their own are listed apart (pim-p2-8).
|
||||
* The card said "You declined" for a weekly meeting when one week was declined,
|
||||
* read from whichever occurrence the calendar held first. `master` is the
|
||||
* series as stored, with its recurrenceOverrides; `declined` holds the
|
||||
* recurrence ids (wall clock in the event's zone), oldest first.
|
||||
*/
|
||||
export function seriesAnswers(
|
||||
master: Partial<CalendarEvent>,
|
||||
emails: readonly string[],
|
||||
): { status: string | null; declined: string[] } {
|
||||
const own = findParticipantByEmails(master, emails);
|
||||
const status = own?.participant.participationStatus ?? null;
|
||||
const declined: string[] = [];
|
||||
for (const [recurrenceId, patch] of Object.entries(master.recurrenceOverrides ?? {})) {
|
||||
if (!patch || typeof patch !== 'object' || (patch as { excluded?: unknown }).excluded === true) continue;
|
||||
const occurrence = applyOverridePatch(master, patch as Record<string, unknown>);
|
||||
const mine = findParticipantByEmails(occurrence, emails);
|
||||
if (mine?.participant.participationStatus === 'declined' && status !== 'declined') declined.push(recurrenceId);
|
||||
}
|
||||
return { status, declined: declined.sort() };
|
||||
}
|
||||
|
||||
/** Whether a parsed invitation is for a whole series, not one occurrence of it. */
|
||||
export function isSeriesInvitation(event: Partial<CalendarEvent> | null | undefined): boolean {
|
||||
return !!event?.recurrenceRules?.length && !event.recurrenceId;
|
||||
}
|
||||
|
||||
export function findParticipantByEmails(
|
||||
event: Partial<CalendarEvent>,
|
||||
emails: readonly string[]
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "رفضت الدعوة.",
|
||||
"answered_tentative_nodate": "أجبت بـ\"ربما\"."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "يتكرر: {rule}",
|
||||
"declined_dates": "رفضت موعد {dates}.",
|
||||
"declined_many": "رفضت {count} من مواعيد هذه السلسلة.",
|
||||
"answers_series": "ينطبق ردك على كل مواعيد السلسلة."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "شخص ما",
|
||||
"invited": "دعاك {name} إلى \"{title}\"",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Heu rebutjat.",
|
||||
"answered_tentative_nodate": "Heu respost potser."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Es repeteix: {rule}",
|
||||
"declined_dates": "Has rebutjat el {dates}.",
|
||||
"declined_many": "Has rebutjat {count} reunions d'aquesta sèrie.",
|
||||
"answers_series": "La teva resposta val per a totes les reunions de la sèrie."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Algú",
|
||||
"invited": "{name} t'ha convidat a \"{title}\"",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Odmítli jste.",
|
||||
"answered_tentative_nodate": "Odpověděli jste možná."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Opakuje se: {rule}",
|
||||
"declined_dates": "Odmítli jste {dates}.",
|
||||
"declined_many": "Odmítli jste {count} schůzek této série.",
|
||||
"answers_series": "Vaše odpověď platí pro všechny schůzky série."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Pokročilé hledání",
|
||||
"from": "Od",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Du afslog.",
|
||||
"answered_tentative_nodate": "Du svarede måske."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Gentages: {rule}",
|
||||
"declined_dates": "Du afslog {dates}.",
|
||||
"declined_many": "Du afslog {count} møder i denne serie.",
|
||||
"answers_series": "Dit svar gælder alle møder i serien."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Nogen",
|
||||
"invited": "{name} har inviteret dig til \"{title}\"",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Sie haben abgesagt.",
|
||||
"answered_tentative_nodate": "Sie haben mit Vielleicht geantwortet."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Wiederholung: {rule}",
|
||||
"declined_dates": "Sie haben am {dates} abgesagt.",
|
||||
"declined_many": "Sie haben {count} Termine dieser Serie abgesagt.",
|
||||
"answers_series": "Ihre Antwort gilt für alle Termine der Serie."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Erweiterte Suche",
|
||||
"from": "Von",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "You declined.",
|
||||
"answered_tentative_nodate": "You answered maybe."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Repeats: {rule}",
|
||||
"declined_dates": "You declined {dates}.",
|
||||
"declined_many": "You declined {count} meetings of this series.",
|
||||
"answers_series": "Your answer counts for every meeting in the series."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Someone",
|
||||
"invited": "{name} invited you to \"{title}\"",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Rechazaste.",
|
||||
"answered_tentative_nodate": "Respondiste quizás."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Se repite: {rule}",
|
||||
"declined_dates": "Rechazaste el {dates}.",
|
||||
"declined_many": "Rechazaste {count} reuniones de esta serie.",
|
||||
"answers_series": "Tu respuesta vale para todas las reuniones de la serie."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Búsqueda avanzada",
|
||||
"from": "De",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Keeldusite.",
|
||||
"answered_tentative_nodate": "Vastasite võib-olla."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Kordub: {rule}",
|
||||
"declined_dates": "Lükkasite tagasi kuupäeva {dates}.",
|
||||
"declined_many": "Lükkasite tagasi {count} selle sarja kohtumist.",
|
||||
"answers_series": "Teie vastus kehtib sarja kõigile kohtumistele."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Keegi",
|
||||
"invited": "{name} kutsus sind sündmusele \"{title}\"",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "رد کردید.",
|
||||
"answered_tentative_nodate": "پاسخ «شاید» دادید."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "تکرار: {rule}",
|
||||
"declined_dates": "جلسهٔ {dates} را رد کردید.",
|
||||
"declined_many": "{count} جلسه از این سری را رد کردید.",
|
||||
"answers_series": "پاسخ شما برای همهٔ جلسههای این سری است."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "کسی",
|
||||
"invited": "{name} شما را به «{title}» دعوت کرد",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Vous avez refusé.",
|
||||
"answered_tentative_nodate": "Vous avez répondu peut-être."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Se répète : {rule}",
|
||||
"declined_dates": "Vous avez refusé le {dates}.",
|
||||
"declined_many": "Vous avez refusé {count} réunions de cette série.",
|
||||
"answers_series": "Votre réponse vaut pour toutes les réunions de la série."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Recherche avancée",
|
||||
"from": "De",
|
||||
|
||||
@@ -5120,6 +5120,12 @@
|
||||
"answered_declined_nodate": "דחית.",
|
||||
"answered_tentative_nodate": "ענית 'אולי'."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "חוזר: {rule}",
|
||||
"declined_dates": "דחית את {dates}.",
|
||||
"declined_many": "דחית {count} פגישות בסדרה הזו.",
|
||||
"answers_series": "התשובה שלך חלה על כל הפגישות בסדרה."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "חיפוש מתקדם",
|
||||
"from": "מ",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Ön elutasította.",
|
||||
"answered_tentative_nodate": "Ön talánnal válaszolt."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Ismétlődik: {rule}",
|
||||
"declined_dates": "Elutasította: {dates}.",
|
||||
"declined_many": "A sorozat {count} alkalmát utasította el.",
|
||||
"answers_series": "A válasza a sorozat minden alkalmára vonatkozik."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Valaki",
|
||||
"invited": "{name} meghívott a(z) „{title}” eseményre",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Hai rifiutato.",
|
||||
"answered_tentative_nodate": "Hai risposto forse."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Si ripete: {rule}",
|
||||
"declined_dates": "Hai rifiutato il {dates}.",
|
||||
"declined_many": "Hai rifiutato {count} riunioni di questa serie.",
|
||||
"answers_series": "La tua risposta vale per tutte le riunioni della serie."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Ricerca avanzata",
|
||||
"from": "Da",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "辞退しました。",
|
||||
"answered_tentative_nodate": "未定と回答しました。"
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "繰り返し: {rule}",
|
||||
"declined_dates": "{dates} は辞退しました。",
|
||||
"declined_many": "このシリーズの {count} 件を辞退しました。",
|
||||
"answers_series": "回答はシリーズのすべての会議に適用されます。"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "詳細検索",
|
||||
"from": "差出人",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "거절했습니다.",
|
||||
"answered_tentative_nodate": "미정으로 응답했습니다."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "반복: {rule}",
|
||||
"declined_dates": "{dates} 일정을 거절했습니다.",
|
||||
"declined_many": "이 시리즈의 일정 {count}개를 거절했습니다.",
|
||||
"answers_series": "답변은 시리즈의 모든 일정에 적용됩니다."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "상세 검색",
|
||||
"from": "보낸 사람",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Jūs noraidījāt.",
|
||||
"answered_tentative_nodate": "Jūs atbildējāt varbūt."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Atkārtojas: {rule}",
|
||||
"declined_dates": "Jūs atteicāt {dates}.",
|
||||
"declined_many": "Jūs atteicāt {count} šīs sērijas sapulces.",
|
||||
"answers_series": "Jūsu atbilde attiecas uz visām sērijas sapulcēm."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Izvērstā meklēšana",
|
||||
"from": "No",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Та татгалзсан.",
|
||||
"answered_tentative_nodate": "Та магадгүй гэж хариулсан."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Давтагдана: {rule}",
|
||||
"declined_dates": "Та {dates}-ны уулзалтаас татгалзсан.",
|
||||
"declined_many": "Та энэ цувралын {count} уулзалтаас татгалзсан.",
|
||||
"answers_series": "Таны хариу цувралын бүх уулзалтад хамаарна."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Хэн нэгэн",
|
||||
"invited": "{name} таныг \"{title}\"-д урилаа",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Du avslo.",
|
||||
"answered_tentative_nodate": "Du svarte kanskje."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Gjentas: {rule}",
|
||||
"declined_dates": "Du avslo {dates}.",
|
||||
"declined_many": "Du avslo {count} møter i denne serien.",
|
||||
"answers_series": "Svaret ditt gjelder alle møtene i serien."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Noen",
|
||||
"invited": "{name} har invitert deg til «{title}»",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Je hebt geweigerd.",
|
||||
"answered_tentative_nodate": "Je hebt misschien geantwoord."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Herhaalt: {rule}",
|
||||
"declined_dates": "U hebt {dates} afgeslagen.",
|
||||
"declined_many": "U hebt {count} afspraken van deze reeks afgeslagen.",
|
||||
"answers_series": "Uw antwoord geldt voor alle afspraken in de reeks."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Geavanceerd zoeken",
|
||||
"from": "Van",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Odrzucono.",
|
||||
"answered_tentative_nodate": "Odpowiedziano „może”."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Powtarza się: {rule}",
|
||||
"declined_dates": "Odrzucono {dates}.",
|
||||
"declined_many": "Odrzucono {count} spotkań z tej serii.",
|
||||
"answers_series": "Twoja odpowiedź dotyczy wszystkich spotkań z serii."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Wyszukiwanie zaawansowane",
|
||||
"from": "Od",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Você recusou.",
|
||||
"answered_tentative_nodate": "Você respondeu talvez."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Repete-se: {rule}",
|
||||
"declined_dates": "Você recusou {dates}.",
|
||||
"declined_many": "Você recusou {count} reuniões desta série.",
|
||||
"answers_series": "Sua resposta vale para todas as reuniões da série."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Pesquisa avançada",
|
||||
"from": "De",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Ai refuzat.",
|
||||
"answered_tentative_nodate": "Ai răspuns poate."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Se repetă: {rule}",
|
||||
"declined_dates": "Ați refuzat {dates}.",
|
||||
"declined_many": "Ați refuzat {count} întâlniri din această serie.",
|
||||
"answers_series": "Răspunsul dvs. este pentru toate întâlnirile din serie."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Cineva",
|
||||
"invited": "{name} te-a invitat la „{title}”",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Вы отклонили.",
|
||||
"answered_tentative_nodate": "Вы ответили «может быть»."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Повторяется: {rule}",
|
||||
"declined_dates": "Вы отклонили встречу {dates}.",
|
||||
"declined_many": "Вы отклонили встреч этой серии: {count}.",
|
||||
"answers_series": "Ваш ответ относится ко всем встречам серии."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Расширенный поиск",
|
||||
"from": "От",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Odmietli ste.",
|
||||
"answered_tentative_nodate": "Odpovedali ste možno."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Opakuje sa: {rule}",
|
||||
"declined_dates": "Odmietli ste {dates}.",
|
||||
"declined_many": "Odmietli ste {count} stretnutí tejto série.",
|
||||
"answers_series": "Vaša odpoveď platí pre všetky stretnutia série."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Niekto",
|
||||
"invited": "{name} vás pozval(a) na „{title}“",
|
||||
|
||||
@@ -4638,6 +4638,12 @@
|
||||
"answered_declined_nodate": "คุณปฏิเสธแล้ว",
|
||||
"answered_tentative_nodate": "คุณตอบว่าอาจจะ"
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "ทำซ้ำ: {rule}",
|
||||
"declined_dates": "คุณปฏิเสธวันที่ {dates}",
|
||||
"declined_many": "คุณปฏิเสธ {count} การประชุมในชุดนี้",
|
||||
"answers_series": "คำตอบของคุณใช้กับทุกการประชุมในชุดนี้"
|
||||
},
|
||||
"settings": {
|
||||
"title": "การตั้งค่า",
|
||||
"org_locked": "กำหนดโดยองค์กรของคุณ",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Reddettiniz.",
|
||||
"answered_tentative_nodate": "Belki dediniz."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Tekrarlanır: {rule}",
|
||||
"declined_dates": "{dates} toplantısını reddettiniz.",
|
||||
"declined_many": "Bu serideki {count} toplantıyı reddettiniz.",
|
||||
"answers_series": "Yanıtınız serideki tüm toplantılar için geçerlidir."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Birisi",
|
||||
"invited": "{name} sizi \"{title}\" etkinliğine davet etti",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "Ви відхилили.",
|
||||
"answered_tentative_nodate": "Ви відповіли «можливо»."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Повторюється: {rule}",
|
||||
"declined_dates": "Ви відхилили зустріч {dates}.",
|
||||
"declined_many": "Ви відхилили зустрічей цієї серії: {count}.",
|
||||
"answers_series": "Ваша відповідь стосується всіх зустрічей серії."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Розширений пошук",
|
||||
"from": "Від",
|
||||
|
||||
@@ -5238,6 +5238,12 @@
|
||||
"answered_declined_nodate": "Bạn đã từ chối.",
|
||||
"answered_tentative_nodate": "Bạn đã trả lời có thể."
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "Lặp lại: {rule}",
|
||||
"declined_dates": "Bạn đã từ chối ngày {dates}.",
|
||||
"declined_many": "Bạn đã từ chối {count} cuộc họp trong chuỗi này.",
|
||||
"answers_series": "Câu trả lời của bạn áp dụng cho mọi cuộc họp trong chuỗi."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Ai đó",
|
||||
"invited": "{name} đã mời bạn tham gia \"{title}\"",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "你已拒絕。",
|
||||
"answered_tentative_nodate": "你已回覆暫定。"
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "重複:{rule}",
|
||||
"declined_dates": "您已拒絕 {dates} 的會議。",
|
||||
"declined_many": "您已拒絕此系列中的 {count} 場會議。",
|
||||
"answers_series": "您的回覆適用於此系列的所有會議。"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "有人",
|
||||
"invited": "{name} 邀請您參加「{title}」",
|
||||
|
||||
@@ -5226,6 +5226,12 @@
|
||||
"answered_declined_nodate": "你已拒绝。",
|
||||
"answered_tentative_nodate": "你已答复暂定。"
|
||||
},
|
||||
"ux_p2_pim_calx": {
|
||||
"repeats": "重复:{rule}",
|
||||
"declined_dates": "您已拒绝 {dates} 的会议。",
|
||||
"declined_many": "您已拒绝此系列中的 {count} 次会议。",
|
||||
"answers_series": "您的回复适用于此系列的所有会议。"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "高级搜索",
|
||||
"from": "发件人",
|
||||
|
||||
Reference in New Issue
Block a user