diff --git a/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx b/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx new file mode 100644 index 000000000..df63353a9 --- /dev/null +++ b/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx @@ -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 => []), + // 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>, + accountMailboxes: {} as Record>>, + }; + + 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>, + 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 | ((state: typeof calendarState) => Partial | 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) => { + const strings: Record = { + 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 { + 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(); + 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(); + 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(); + 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(); + await screen.findByText('Weekly sync with Ben'); + expect(screen.queryByTestId('invitation-repeats')).not.toBeInTheDocument(); + expect(screen.queryByTestId('invitation-answers-series')).not.toBeInTheDocument(); + }); +}); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 5928868d5..c794eac84 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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(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(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 )} + {seriesRule && ( + + + {tSeries('repeats', { rule: seriesRule })} + + )} {summary.location && ( @@ -1112,9 +1162,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp )} {/* Info / actor messages */} - {showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && ( + {showDetails && (bannerInfo || declinedInfo || actorMessage || actorSummary?.participationComment) && (
{bannerInfo &&

{bannerInfo}

} + {declinedInfo &&

{declinedInfo}

} {cancellationOutdated &&

{t('cancel_outdated')}

} {actorMessage &&

{actorMessage}

} {actorSummary?.participationComment && ( @@ -1206,6 +1257,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {t('decline')} + {isSeries && ( + {tSeries('answers_series')} + )}
)} diff --git a/lib/__tests__/series-answers-ux-p2-pim.test.ts b/lib/__tests__/series-answers-ux-p2-pim.test.ts new file mode 100644 index 000000000..f1f66fe95 --- /dev/null +++ b/lib/__tests__/series-answers-ux-p2-pim.test.ts @@ -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): Partial { + return { + uid: 'w', start: '2026-11-03T16:00:00', recurrenceRules: [{ frequency: 'weekly' }], + participants: { me, ben }, recurrenceOverrides: overrides, + } as unknown as Partial; +} + +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, ['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)).toBe(false); + expect(isSeriesInvitation({ uid: 'x' })).toBe(false); + }); +}); diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index 60fdff12b..0c16debea 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -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, + 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); + 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 | null | undefined): boolean { + return !!event?.recurrenceRules?.length && !event.recurrenceId; +} + export function findParticipantByEmails( event: Partial, emails: readonly string[] diff --git a/locales/ar/common.json b/locales/ar/common.json index db3f91ff9..b3d2379d4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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}\"", diff --git a/locales/ca/common.json b/locales/ca/common.json index 370dd4902..bdfacf30b 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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}\"", diff --git a/locales/cs/common.json b/locales/cs/common.json index c77cddf2a..40f6e5f45 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index 89522ce74..d9cb6b76c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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}\"", diff --git a/locales/de/common.json b/locales/de/common.json index 5f077d7fd..ec24e06d4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index ea30812aa..0b497c13d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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}\"", diff --git a/locales/es/common.json b/locales/es/common.json index e28d12f5f..9fefdd4b2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/et/common.json b/locales/et/common.json index 584c4f2b7..fbcf3ab92 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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}\"", diff --git a/locales/fa/common.json b/locales/fa/common.json index 1c49696fe..99f315963 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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}» دعوت کرد", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1a6a2f71c..f27b3f84e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index e15809a86..2a2eae497 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "מ", diff --git a/locales/hu/common.json b/locales/hu/common.json index 23c5c88d4..957a4985e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 73fda3f89..9024cfbf1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6b2f3e82b..2ade125ae 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "差出人", diff --git a/locales/ko/common.json b/locales/ko/common.json index fe9560d3f..9a674b669 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "보낸 사람", diff --git a/locales/lv/common.json b/locales/lv/common.json index 6c2792f2a..abe70ff84 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5638c0661..eded34e01 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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}\"-д урилаа", diff --git a/locales/nb/common.json b/locales/nb/common.json index 875523efe..a5d992a26 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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}»", diff --git a/locales/nl/common.json b/locales/nl/common.json index c24f86f62..436a8fd2b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index 90a292bbd..2f576bb83 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index f38b2bc69..c346a8c8d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index e0e5ef680..e305011c3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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}”", diff --git a/locales/ru/common.json b/locales/ru/common.json index 761f4d4dd..4267d5329 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "От", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5b1572ca2..23e545cd2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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}“", diff --git a/locales/th/common.json b/locales/th/common.json index 4e7043c82..4a785f5fa 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -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": "กำหนดโดยองค์กรของคุณ", diff --git a/locales/tr/common.json b/locales/tr/common.json index ac8dd7b92..e4419a9d8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index b0d90b0f8..0b2dc73fe 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Від", diff --git a/locales/vi/common.json b/locales/vi/common.json index 367c03dd8..89d844cd4 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -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}\"", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0b3d88f49..254ba30ee 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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}」", diff --git a/locales/zh/common.json b/locales/zh/common.json index 7ac44e08e..1f9dbdb79 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "发件人",