diff --git a/components/calendar/__tests__/event-modal-participant-limit.test.tsx b/components/calendar/__tests__/event-modal-participant-limit.test.tsx index a9cde677a..f0394953b 100644 --- a/components/calendar/__tests__/event-modal-participant-limit.test.tsx +++ b/components/calendar/__tests__/event-modal-participant-limit.test.tsx @@ -6,7 +6,8 @@ import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; // Stalwart 1.0 refuses to save an event with more participants than the // calendars capability's maxParticipantsPerEvent (20 by default); 0.16 // advertises the same value without enforcing it. The editor warns, and -// still saves. +// still saves. The organizer entry a save adds counts: live on 1.0, 19 +// attendees and the organizer are accepted, 20 and the organizer refused. vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); @@ -50,13 +51,13 @@ function editMeeting(attendees: number, maxParticipants: number | null) { } describe('EventModal participant limit', () => { - it('warns when the event has more attendees than the server allows on Stalwart 1.0', () => { - editMeeting(3, 2); + it('warns when the attendees and the organizer are more than the server allows on Stalwart 1.0', () => { + editMeeting(20, 20); expect(screen.getByRole('status')).toHaveTextContent('participants.limit_warning'); }); - it('stays quiet within the limit', () => { - editMeeting(2, 2); + it('stays quiet within the limit, the organizer counted', () => { + editMeeting(19, 20); expect(screen.queryByText('participants.limit_warning')).toBeNull(); }); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index f8b2066a9..a3f4a5fd6 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -480,8 +480,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars); const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents); - // Loads the range the view shows now - not the store's dateRange, which a - // failed load leaves at the range loaded before it. + // Loads the range the view shows now - after a failed load the same as the + // store's dateRange, which then holds the range asked for. const retryEventsLoad = useCallback(() => { if (!client || !dateRange) return; if (multiAccountEnabled && accountClients.length > 0) { diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index fe5485b60..7f98e7ab1 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -1211,8 +1211,9 @@ export function EventModal({ onRemove={handleRemoveAttendee} /> {/* Only a warning: 0.16 advertises the limit without enforcing - it, Stalwart 1.0 refuses to save more attendees. */} - {maxParticipants != null && attendees.length > maxParticipants && ( + it, Stalwart 1.0 refuses to save more participants - the + organizer entry a save adds counts too. */} + {maxParticipants != null && attendees.length > 0 && attendees.length + 1 > maxParticipants && (

{t("participants.limit_warning", { max: maxParticipants })} diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index b45a8b883..56b01a9a1 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -2,6 +2,7 @@ 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'; +import { jmapSetError } from '@/lib/jmap/jmap-error'; const mocks = vi.hoisted(() => { const pushMock = vi.fn(); @@ -113,6 +114,7 @@ vi.mock('next-intl', () => ({ maybe: 'Maybe', decline: 'Decline', select_calendar: 'Select calendar', + participant_limit: 'The server allows at most {max} participants per event.', }; let message = strings[key] ?? key; @@ -748,4 +750,86 @@ describe('CalendarInvitationBanner', () => { expect(screen.getByText('Proposed changes applied.')).toBeInTheDocument(); }); -}); \ No newline at end of file + + // Stalwart 1.0 refuses an event with more participants than + // maxParticipantsPerEvent; the banner says so instead of "could not + // complete". + describe('when Stalwart 1.0 refuses the participant count', () => { + const LIMIT = jmapSetError('An event cannot have more than 20 participants.', { + type: 'invalidProperties', properties: ['participants'], + }); + const invitation = { + uid: 'uid-9', + title: 'All hands', + start: '2026-03-28T10:00:00Z', + participants: { + attendee: { + '@type': 'Participant', email: 'user@example.com', name: 'User', + roles: { attendee: true }, participationStatus: 'needs-action', + }, + }, + }; + + it('says so when adding the invitation to the calendar', async () => { + mocks.calendarState.importEvents.mockRejectedValue(LIMIT); + mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]); + render(); + await waitFor(() => expect(screen.getByRole('heading', { name: 'All hands' })).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Add to calendar')); + + await waitFor(() => { + expect(screen.getByText('The server allows at most 20 participants per event.')).toBeInTheDocument(); + }); + expect(mocks.calendarState.importEvents).toHaveBeenCalledWith( + expect.anything(), [expect.objectContaining({ uid: 'uid-9' })], 'cal-1', { throwIfNoneImported: true }, + ); + }); + + it('says so when answering an invitation that is not in the calendar yet', async () => { + mocks.calendarState.importEvents.mockRejectedValue(LIMIT); + mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]); + render(); + await waitFor(() => expect(screen.getByText('Accept')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Accept')); + + await waitFor(() => { + expect(screen.getByText('The server allows at most 20 participants per event.')).toBeInTheDocument(); + }); + // Asked to reject: it used to resolve to 0 and say nothing more. + expect(mocks.calendarState.importEvents).toHaveBeenCalledWith( + expect.anything(), [expect.objectContaining({ uid: 'uid-9' })], 'cal-1', { throwIfNoneImported: true }, + ); + }); + + it('says so when applying a proposal', async () => { + mocks.calendarState.updateEvent = vi.fn().mockRejectedValue(LIMIT); + mocks.calendarState.events = [{ + id: 'event-11', uid: 'uid-11', title: 'Sync', start: '2026-03-27T09:00:00Z', duration: 'PT1H', + participants: { + organizer: { '@type': 'Participant', email: 'user@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + attendee: { '@type': 'Participant', email: 'alice@example.com', roles: { attendee: true }, participationStatus: 'tentative' }, + }, + }]; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ + uid: 'uid-11', title: 'Sync', start: '2026-03-27T10:00:00Z', duration: 'PT1H', + participants: { + organizer: { '@type': 'Participant', email: 'user@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + attendee: { '@type': 'Participant', email: 'alice@example.com', roles: { attendee: true }, participationStatus: 'tentative' }, + }, + }]); + render(); + await waitFor(() => expect(screen.getByText('Apply proposed changes')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Apply proposed changes')); + + await waitFor(() => { + expect(screen.getByText('The server allows at most 20 participants per event.')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index a098c57d1..b3d72fd4f 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -627,7 +627,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } else if (eventForRsvp) { setActionError(t('action_failed')); } else if (calId) { - const imported = await importEvents(client, [parsedEvent], calId); + // A refusal (Stalwart 1.0's participant limit, say) lands in the + // catch below and is said as it is. + const imported = await importEvents(client, [parsedEvent], calId, { throwIfNoneImported: true }); if (imported > 0) { const newEvent = useCalendarStore.getState().events.find( (e) => e.uid === parsedEvent.uid @@ -683,15 +685,15 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setActionError(null); setIsProcessing(true); try { - const count = await importEvents(client, [parsedEvent], calId); + const count = await importEvents(client, [parsedEvent], calId, { throwIfNoneImported: true }); if (count > 0) { setActionNotice(t('added')); setState('parsed'); } else { setActionError(t('action_failed')); } - } catch { - setActionError(t('action_failed')); + } catch (err) { + setActionError(describeError(err, t('action_failed'))); } finally { setIsProcessing(false); } @@ -746,8 +748,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setActionNotice(t('proposal_applied')); setState('parsed'); setRsvpStatus(null); - } catch { - setActionError(t('action_failed')); + } catch (err) { + setActionError(describeError(err, t('action_failed'))); } finally { setIsProcessing(false); } diff --git a/lib/__tests__/jmap-calendar-query-range.test.ts b/lib/__tests__/jmap-calendar-query-range.test.ts index e2a5f8f5e..c4f292ec6 100644 --- a/lib/__tests__/jmap-calendar-query-range.test.ts +++ b/lib/__tests__/jmap-calendar-query-range.test.ts @@ -410,6 +410,112 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => { await expect(client.queryCalendarEvents(WINDOW)).rejects.toThrow(/single FilterCondition/); expect(server.queries()).toHaveLength(3); }); + + // 1.0 expands recurrences only for one AND of single conditions; an OR, + // a NOT or a condition of several keys next to the bounds is refused. + it.each<[string, Record]>([ + ['an OR', { operator: 'OR', conditions: [{ text: 'budget' }, { text: 'review' }] }], + ['a condition of two keys', { title: 'budget', description: 'review' }], + ])('sends an AND holding %s as it is, not expanded, on Stalwart 1.0', async (_label, other) => { + await connect('1.0'); + const filter = { operator: 'AND' as const, conditions: [{ after: WINDOW.after }, { before: WINDOW.before }, other] }; + + await client.queryCalendarEvents(filter); + + expect(expandedQueries()).toHaveLength(0); + expect(server.queries().map((q) => q.args.filter)).toEqual([filter]); + }); +}); + +// With a limit, the chunks of a long range go out a few per request in the +// sort order. When one of them needs another query - it was refused and is +// halved, or it filled its page with an occurrence found before - the chunks +// after it in that request are asked again after it, and what they returned +// is dropped: the result keeps the sort order. +describe('limited reads of several chunks per request', () => { + // Chunks of 99 days from 2025-01-01, four per request. + const WINDOW = { after: '2025-01-01T00:00:00', before: '2027-02-16T23:59:59' }; + let fetchSpy: ReturnType; + let client: JMAPClient; + let server: StalwartCalendarMock; + + async function connect(version: StalwartVersion, occurrences: RangeOccurrence[], maxInstances?: number) { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + client = await connectCalendarClient(fetchSpy, stalwartCalendarSession(version, { maxExpandedQueryDuration: 'P100D' })); + server = installStalwartCalendarMock(fetchSpy, { + version, + objects: { + series: { + id: 'series', '@type': 'Event', uid: 'series@example.com', title: 'Standup', baseEventId: 'series', + start: '2025-01-02T09:00:00', duration: 'PT1H', recurrenceRule: { frequency: 'daily' }, + recurrenceOverrides: null, calendarIds: { cal: true }, + }, + }, + occurrences: occurrenceObjects('series', occurrences), + answer: expandingQueryAnswer(version, { seriesId: 'series', occurrences, maxInstances }), + }); + } + + afterEach(() => { + client.disconnect(); + fetchSpy.mockRestore(); + }); + + const byRequest = () => { + const requests = new Map>(); + for (const q of server.queries().filter((query) => query.args.expandRecurrences)) { + const filter = q.args.filter as { after: string; before: string }; + requests.set(q.request, [...(requests.get(q.request) ?? []), { after: filter.after, before: filter.before, position: q.args.position }]); + } + return [...requests.values()]; + }; + + it('asks again after a chunk that filled its page with an occurrence found before on Stalwart 1.0', async () => { + await connect('1.0', [ + { id: 'o-a', start: '2025-01-02T09:00:00', seconds: 3600 }, + // Across the border of the fourth and fifth chunk: both find it. + { id: 'o-b', start: '2026-01-31T23:30:00', seconds: 3600 }, + { id: 'o-c', start: '2026-03-01T09:00:00', seconds: 3600 }, + { id: 'o-d', start: '2026-04-01T09:00:00', seconds: 3600 }, + ]); + + const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: true }], 3); + + const [first, second, third] = byRequest(); + expect(first).toHaveLength(4); + // The fifth chunk's page of one is o-b again: it is asked on from + // there, with the three chunks after it. + expect(second).toHaveLength(4); + expect(third).toHaveLength(4); + expect(third[0]).toEqual({ ...second[0], position: 1 }); + expect(third.slice(1)).toEqual(second.slice(1)); + expect(events.map((e) => e.id)).toEqual(['o-a', 'o-b', 'o-c']); + }); + + it.each<[StalwartVersion]>([['0.16'], ['1.0']])( + 'asks the rest of a request again after the halves of a refused chunk on Stalwart %s', + async (version) => { + // The first chunk expands to three occurrences, one more than allowed. + await connect(version, [ + { id: 'o-jan', start: '2025-01-02T09:00:00', seconds: 3600 }, + { id: 'o-feb', start: '2025-02-02T09:00:00', seconds: 3600 }, + { id: 'o-mar', start: '2025-03-02T09:00:00', seconds: 3600 }, + { id: 'o-may', start: '2025-05-01T09:00:00', seconds: 3600 }, + ], 2); + + const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: true }], 4); + + const [first, second] = byRequest(); + expect(first).toHaveLength(4); + // The halves of the first chunk, then the second and third chunks again. + expect(second).toHaveLength(4); + expect(second[0].after).toBe(first[0].after); + expect(second[1].before).toBe(first[0].before); + expect(second.slice(2)).toEqual(first.slice(1, 3)); + expect(byRequest()).toHaveLength(2); + expect(events.map((e) => e.id)).toEqual(['o-jan', 'o-feb', 'o-mar', 'o-may']); + }, + ); }); // Stalwart 1.0 fails every CalendarEvent get and query whose `timeZone` its @@ -523,6 +629,17 @@ describe('a time zone the server does not know', () => { expect(warn).not.toHaveBeenCalled(); }); + it('sends a zone typed in another letter case as Intl spells it on Stalwart 1.0', async () => { + await connect('1.0'); + useSettingsStore.setState({ timeZone: 'europe/berlin' }); + + await client.getAllCalendars(); + await client.queryCalendarEvents(GRID); + + expect(zones().every((zone) => zone === 'Europe/Berlin')).toBe(true); + expect(warn).not.toHaveBeenCalled(); + }); + it('sends a zone Stalwart 1.0 knows as it is', async () => { await connect('1.0'); useSettingsStore.setState({ timeZone: 'Europe/Berlin' }); diff --git a/lib/__tests__/timezone.test.ts b/lib/__tests__/timezone.test.ts index 54477f6c8..dfcd8aa48 100644 --- a/lib/__tests__/timezone.test.ts +++ b/lib/__tests__/timezone.test.ts @@ -9,6 +9,7 @@ import { getWallClock, isDisplayToday, isValidTimeZone, + canonicalTimeZone, listTimeZones, moveLocalDateTime, resolveTimeZone, @@ -48,6 +49,14 @@ describe('resolveTimeZone', () => { expect(resolveTimeZone('Asia/Tokyo', 'Europe/Berlin')).toBe('Asia/Tokyo'); }); + // Stalwart 1.0 refuses "europe/berlin" as a query's timeZone, and both + // 0.16 and 1.0 drop it from an event, storing the event as floating. + it('spells a zone typed in another case the way Intl does', () => { + expect(resolveTimeZone('europe/berlin', 'UTC')).toBe('Europe/Berlin'); + expect(canonicalTimeZone('AMERICA/NEW_YORK')).toBe('America/New_York'); + expect(canonicalTimeZone('Mars/Olympus_Mons')).toBeNull(); + }); + it('validates zone ids against Intl', () => { expect(isValidTimeZone('America/New_York')).toBe(true); expect(isValidTimeZone('Not/A_Zone')).toBe(false); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 83342b2fa..706b027bc 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -25,7 +25,7 @@ export { SchedulingDeniedError }; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; import { folderDepthError } from "@/lib/file-errors"; import { mediaTypeEssence, OCTET_STREAM } from "@/lib/media-type"; -import { getEffectiveTimeZone, moveLocalDateTime, toLocalDateTime } from "@/lib/timezone"; +import { canonicalTimeZone, getEffectiveTimeZone, moveLocalDateTime, toLocalDateTime } from "@/lib/timezone"; import { buildEmailSort, compareEmails, hasKeywordLevels, hasTextLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; import type { AccountInfo } from "./types"; import { stalwartChangeId } from "./stalwart-state"; @@ -612,11 +612,14 @@ function normalizeCalendarTask(task: CalendarTask): CalendarTask { * shifts range boundaries and floating-event times for any user not in UTC. * A zone the server's time zone database lacks is read as UTC by 0.16 and * refused by 1.0, which fails the whole call; JMAPClient.calendarTimeZone - * decides what is actually sent. + * decides what is actually sent. The name goes out as Intl spells it: 1.0 + * matches it exactly, so a setting typed "europe/berlin" would read as + * unknown. */ function getUserTimeZone(): string | undefined { try { - return getEffectiveTimeZone() || undefined; + const zone = getEffectiveTimeZone(); + return (zone && canonicalTimeZone(zone)) || undefined; } catch { return undefined; } @@ -858,10 +861,14 @@ interface QueryRange { * conditions of a top-level AND - how global search sends a range with * several text parts. Rebuilt, such an AND stays flat: Stalwart 1.0 expands * recurrences only for a single FilterCondition, and reads a condition of - * two keys as an AND of its own. + * two keys as an AND of its own. An AND that holds anything else - an OR, a + * NOT, a condition of several keys - is no range: 1.0 would refuse to expand + * it, so it goes out as it is. */ function rangeOf(filter: CalendarEventFilter): QueryRange { if (filter.operator === 'AND' && Array.isArray(filter.conditions)) { + const plain = (condition: CalendarEventFilter) => !condition.operator && Object.keys(condition).length === 1; + if (!filter.conditions.every(plain)) return { withBounds: () => ({ ...filter }) }; let after: string | undefined; let before: string | undefined; const rest = filter.conditions.filter((condition) => { diff --git a/lib/timezone.ts b/lib/timezone.ts index 733c14cff..994a278fd 100644 --- a/lib/timezone.ts +++ b/lib/timezone.ts @@ -63,15 +63,37 @@ export function isValidTimeZone(tz: unknown): tz is string { return valid; } +const canonicalCache = new Map(); + /** - * Resolve a stored `timeZone` setting to a concrete IANA zone. `auto`, empty - * or unknown values (a zone synced from a browser with a newer tz database, - * a hand-edited import) fall back to the browser zone instead of throwing - * later inside Intl. + * `tz` as `Intl` spells it ("europe/berlin" -> "Europe/Berlin"), or null when + * `Intl` rejects it. Intl takes a zone in any letter case; Stalwart 1.0 + * matches the name exactly and refuses one it does not know. + */ +export function canonicalTimeZone(tz: string): string | null { + let canonical = canonicalCache.get(tz); + if (canonical === undefined) { + try { + canonical = new Intl.DateTimeFormat(undefined, { timeZone: tz }).resolvedOptions().timeZone || null; + } catch { + canonical = null; + } + canonicalCache.set(tz, canonical); + } + return canonical; +} + +/** + * Resolve a stored `timeZone` setting to a concrete IANA zone, spelled the + * way Intl spells it. `auto`, empty or unknown values (a zone synced from a + * browser with a newer tz database, a hand-edited import) fall back to the + * browser zone instead of throwing later inside Intl. A zone in another + * letter case would be refused by Stalwart 1.0 as an argument, and dropped + * from an event by both 0.16 and 1.0, which stored it as floating. */ export function resolveTimeZone(setting: string | null | undefined, browserTimeZone = getBrowserTimeZone()): string { if (!setting || setting === AUTO_TIME_ZONE) return browserTimeZone; - return isValidTimeZone(setting) ? setting : browserTimeZone; + return canonicalTimeZone(setting) ?? browserTimeZone; } /** The zone all instants should be displayed in: the user's override, else the browser's. */ diff --git a/locales/ar/common.json b/locales/ar/common.json index 21bfb88fd..e84dce944 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "أنت المنظّم", "you_attendee": "أنت أحد الحاضرين", "no_participants": "لا يوجد مشاركون", - "limit_warning": "أكثر من {max} مشاركًا: قد يرفض الخادم حفظ هذا الحدث. يمكن للمسؤول رفع هذا الحد.", + "limit_warning": "أكثر من {max, plural, zero {# مشارك} one {مشارك واحد} two {مشاركَين} few {# مشاركين} many {# مشاركًا} other {# مشارك}}، بمن فيهم المنظّم: قد يرفض الخادم حفظ هذا الحدث. يمكن للمسؤول رفع هذا الحد.", "count": "{count, plural, one {# مشارك} other {# مشاركين}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "حفظ دون دعوات", "rsvp_updated": "تم تحديث الرد", "rsvp_error": "فشل تحديث الرد", - "participant_limit": "يسمح الخادم بحد أقصى {max} مشاركًا لكل حدث، لذا رفض هذا التغيير. يمكن للمسؤول رفع هذا الحد.", + "participant_limit": "يسمح الخادم بحد أقصى {max, plural, zero {# مشارك} one {مشارك واحد} two {مشاركَين} few {# مشاركين} many {# مشاركًا} other {# مشارك}} لكل حدث، بمن فيهم المنظّم، لذا رفض هذا التغيير. يمكن للمسؤول رفع هذا الحد.", "event_duplicated": "تم تكرار الحدث", "event_error": "فشل حفظ الحدث", "task_due": "موعد استحقاق المهمة", diff --git a/locales/ca/common.json b/locales/ca/common.json index faa2ba8db..38912962d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Sou l'organitzador", "you_attendee": "Sou un assistent", "no_participants": "Cap participant", - "limit_warning": "Més de {max} participants: és possible que el servidor es negui a desar aquest esdeveniment. Un administrador pot augmentar el límit.", + "limit_warning": "Més de {max, plural, one {# participant} other {# participants}}, inclòs l'organitzador: és possible que el servidor es negui a desar aquest esdeveniment. Un administrador pot augmentar el límit.", "count": "{count, plural, one {# participant} other {# participants}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Desa sense invitacions", "rsvp_updated": "Resposta actualitzada", "rsvp_error": "No s'ha pogut actualitzar la resposta", - "participant_limit": "El servidor permet com a màxim {max} participants per esdeveniment, per això ha rebutjat aquest canvi. Un administrador pot augmentar el límit.", + "participant_limit": "El servidor permet com a màxim {max, plural, one {# participant} other {# participants}} per esdeveniment, inclòs l'organitzador, per això ha rebutjat aquest canvi. Un administrador pot augmentar el límit.", "event_duplicated": "Esdeveniment duplicat", "event_error": "No s'ha pogut desar l'esdeveniment", "task_due": "Tasca pendent", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5c121907b..43322b71d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Jste organizátorem", "you_attendee": "Jste účastníkem", "no_participants": "Žádní účastníci", - "limit_warning": "Více než {max} účastníků: server může uložení této události odmítnout. Správce může limit zvýšit.", + "limit_warning": "Více než {max, plural, one {# účastník} few {# účastníci} many {# účastníka} other {# účastníků}} včetně organizátora: server může uložení této události odmítnout. Správce může limit zvýšit.", "count": "{count, plural, one {1 účastník} few {# účastníci} other {# účastníků}}" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "Uložit bez pozvánek", "rsvp_updated": "Odpověď byla aktualizována", "rsvp_error": "Aktualizace odpovědi selhala", - "participant_limit": "Server povoluje nejvýše {max} účastníků na událost, proto tuto změnu odmítl. Správce může limit zvýšit.", + "participant_limit": "Server povoluje nejvýše {max, plural, one {# účastníka} few {# účastníky} many {# účastníka} other {# účastníků}} na událost včetně organizátora, proto tuto změnu odmítl. Správce může limit zvýšit.", "event_duplicated": "Událost byla duplikována", "event_error": "Uložení události selhalo", "task_due": "Termín úkolu", diff --git a/locales/da/common.json b/locales/da/common.json index 827d3abe9..970295f9b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Du er arrangøren", "you_attendee": "Du er deltager", "no_participants": "Ingen deltagere", - "limit_warning": "Mere end {max} deltagere: serveren kan afvise at gemme denne begivenhed. En administrator kan hæve grænsen.", + "limit_warning": "Mere end {max, plural, one {# deltager} other {# deltagere}}, arrangøren medregnet: serveren kan afvise at gemme denne begivenhed. En administrator kan hæve grænsen.", "count": "{count, plural, one {# deltager} other {# deltagere}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Gem uden invitationer", "rsvp_updated": "Svar opdateret", "rsvp_error": "Kunne ikke opdatere svar", - "participant_limit": "Serveren tillader højst {max} deltagere pr. begivenhed, så ændringen blev afvist. En administrator kan hæve grænsen.", + "participant_limit": "Serveren tillader højst {max, plural, one {# deltager} other {# deltagere}} pr. begivenhed, arrangøren medregnet, så ændringen blev afvist. En administrator kan hæve grænsen.", "event_duplicated": "Begivenhed duplikeret", "event_error": "Kunne ikke gemme begivenhed", "task_due": "Opgave forfalder", diff --git a/locales/de/common.json b/locales/de/common.json index e4e5c79d8..35cfd77f0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Sie sind der Organisator", "you_attendee": "Sie sind ein Teilnehmer", "no_participants": "Keine Teilnehmer", - "limit_warning": "Mehr als {max} Teilnehmer: Der Server lehnt das Speichern dieses Termins möglicherweise ab. Ihr Administrator kann das Limit erhöhen.", + "limit_warning": "Mehr als {max, plural, one {# Teilnehmer} other {# Teilnehmer}}, den Organisator mitgezählt: Der Server lehnt das Speichern dieses Termins möglicherweise ab. Ihr Administrator kann das Limit erhöhen.", "count": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "Ohne Einladungen speichern", "rsvp_updated": "Antwort aktualisiert", "rsvp_error": "Antwort konnte nicht aktualisiert werden", - "participant_limit": "Der Server erlaubt höchstens {max} Teilnehmer pro Termin und hat die Änderung deshalb abgelehnt. Ihr Administrator kann das Limit erhöhen.", + "participant_limit": "Der Server erlaubt höchstens {max, plural, one {# Teilnehmer} other {# Teilnehmer}} pro Termin, den Organisator mitgezählt, und hat die Änderung deshalb abgelehnt. Ihr Administrator kann das Limit erhöhen.", "event_duplicated": "Termin dupliziert", "event_error": "Termin konnte nicht gespeichert werden", "task_due": "Aufgabe fällig", diff --git a/locales/en/common.json b/locales/en/common.json index 907043778..810ca4e56 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "You are the organizer", "you_attendee": "You are an attendee", "no_participants": "No participants", - "limit_warning": "More than {max} participants: the server may refuse to save this event. An administrator can raise the limit.", + "limit_warning": "More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit.", "count": "{count, plural, one {# participant} other {# participants}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Save without invitations", "rsvp_updated": "Response updated", "rsvp_error": "Failed to update response", - "participant_limit": "The server allows at most {max} participants per event, so it refused this change. An administrator can raise the limit.", + "participant_limit": "The server allows at most {max, plural, one {# participant} other {# participants}} per event, including the organizer, so it refused this change. An administrator can raise the limit.", "event_duplicated": "Event duplicated", "event_error": "Failed to save event", "task_due": "Task due", diff --git a/locales/es/common.json b/locales/es/common.json index 8aafd8070..55aba883f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Eres el organizador", "you_attendee": "Eres un participante", "no_participants": "Sin participantes", - "limit_warning": "Más de {max} participantes: es posible que el servidor se niegue a guardar este evento. Un administrador puede aumentar el límite.", + "limit_warning": "Más de {max, plural, one {# participante} other {# participantes}}, incluido el organizador: es posible que el servidor se niegue a guardar este evento. Un administrador puede aumentar el límite.", "count": "{count, plural, one {# participante} other {# participantes}}" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "Guardar sin invitaciones", "rsvp_updated": "Respuesta actualizada", "rsvp_error": "Error al actualizar la respuesta", - "participant_limit": "El servidor permite como máximo {max} participantes por evento, por lo que rechazó este cambio. Un administrador puede aumentar el límite.", + "participant_limit": "El servidor permite como máximo {max, plural, one {# participante} other {# participantes}} por evento, incluido el organizador, por lo que rechazó este cambio. Un administrador puede aumentar el límite.", "event_duplicated": "Evento duplicado", "event_error": "Error al guardar el evento", "task_due": "Tarea vencida", diff --git a/locales/fa/common.json b/locales/fa/common.json index bbc529b61..26d725e09 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "شما برگزارکننده هستید", "you_attendee": "شما شرکت‌کننده هستید", "no_participants": "شرکت‌کننده‌ای نیست", - "limit_warning": "بیش از {max} شرکت‌کننده: ممکن است سرور ذخیره این رویداد را رد کند. مدیر سیستم می‌تواند این محدودیت را افزایش دهد.", + "limit_warning": "بیش از {max, plural, one {# شرکت‌کننده} other {# شرکت‌کننده}}، با احتساب برگزارکننده: ممکن است سرور ذخیره این رویداد را رد کند. مدیر سیستم می‌تواند این محدودیت را افزایش دهد.", "count": "{count, plural, one {# شرکت‌کننده} other {# شرکت‌کننده}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "ذخیره بدون دعوت‌نامه", "rsvp_updated": "پاسخ به‌روزرسانی شد", "rsvp_error": "به‌روزرسانی پاسخ ناموفق بود", - "participant_limit": "سرور حداکثر {max} شرکت‌کننده برای هر رویداد را می‌پذیرد، به همین دلیل این تغییر را رد کرد. مدیر سیستم می‌تواند این محدودیت را افزایش دهد.", + "participant_limit": "سرور برای هر رویداد، با احتساب برگزارکننده، حداکثر {max, plural, one {# شرکت‌کننده} other {# شرکت‌کننده}} را می‌پذیرد، به همین دلیل این تغییر را رد کرد. مدیر سیستم می‌تواند این محدودیت را افزایش دهد.", "event_duplicated": "رویداد کپی شد", "event_error": "ذخیره رویداد ناموفق بود", "task_due": "موعد وظیفه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 3bec69b0d..075fc59a9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Vous êtes l'organisateur", "you_attendee": "Vous êtes un participant", "no_participants": "Aucun participant", - "limit_warning": "Plus de {max} participants : le serveur risque de refuser d'enregistrer cet événement. Un administrateur peut relever la limite.", + "limit_warning": "Plus de {max, plural, one {# participant} other {# participants}}, organisateur compris : le serveur risque de refuser d'enregistrer cet événement. Un administrateur peut relever la limite.", "count": "{count, plural, one {# participant} other {# participants}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Enregistrer sans invitations", "rsvp_updated": "Réponse mise à jour", "rsvp_error": "Échec de la mise à jour de la réponse", - "participant_limit": "Le serveur autorise au plus {max} participants par événement, il a donc refusé cette modification. Un administrateur peut relever la limite.", + "participant_limit": "Le serveur autorise au plus {max, plural, one {# participant} other {# participants}} par événement, organisateur compris, il a donc refusé cette modification. Un administrateur peut relever la limite.", "event_duplicated": "Événement dupliqué", "event_error": "Échec de l'enregistrement de l'événement", "task_due": "Tâche à échéance", diff --git a/locales/he/common.json b/locales/he/common.json index f45f535cb..0f105b76a 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2818,7 +2818,7 @@ "you_organizer": "אתה המארגן", "you_attendee": "אתה משתתף", "no_participants": "אין משתתפים", - "limit_warning": "יותר מ-{max} משתתפים: ייתכן שהשרת יסרב לשמור את האירוע. מנהל המערכת יכול להגדיל את המגבלה.", + "limit_warning": "יותר מ-{max, plural, one {# משתתף} other {# משתתפים}}, כולל המארגן: ייתכן שהשרת יסרב לשמור את האירוע. מנהל המערכת יכול להגדיל את המגבלה.", "count": "{count, plural, one {משתתף אחד} other {# משתתפים}}", "edit": "ערוך" }, @@ -2967,7 +2967,7 @@ "save_without_invitations": "שמור בלי הזמנות", "rsvp_updated": "התגובה עודכנה", "rsvp_error": "עדכון התגובה נכשל", - "participant_limit": "השרת מאפשר עד {max} משתתפים לאירוע, ולכן דחה את השינוי. מנהל המערכת יכול להגדיל את המגבלה.", + "participant_limit": "השרת מאפשר עד {max, plural, one {# משתתף} other {# משתתפים}} לאירוע, כולל המארגן, ולכן דחה את השינוי. מנהל המערכת יכול להגדיל את המגבלה.", "event_duplicated": "האירוע שוכפל", "event_error": "שמירת האירוע נכשלה", "task_due": "משימה בשל", diff --git a/locales/hu/common.json b/locales/hu/common.json index df950411f..62e4b6ea3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Te vagy a szervező", "you_attendee": "Résztvevő vagy", "no_participants": "Nincs résztvevő", - "limit_warning": "Több mint {max} résztvevő: a szerver elutasíthatja az esemény mentését. A rendszergazda megemelheti a korlátot.", + "limit_warning": "Több mint {max, plural, one {# résztvevő} other {# résztvevő}}, a szervezőt is beleértve: a szerver elutasíthatja az esemény mentését. A rendszergazda megemelheti a korlátot.", "count": "{count, plural, one {# résztvevő} other {# résztvevő}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Mentés meghívók nélkül", "rsvp_updated": "Válasz frissítve", "rsvp_error": "Nem sikerült frissíteni a választ", - "participant_limit": "A szerver eseményenként legfeljebb {max} résztvevőt enged, ezért elutasította a módosítást. A rendszergazda megemelheti a korlátot.", + "participant_limit": "A szerver eseményenként legfeljebb {max, plural, one {# résztvevőt} other {# résztvevőt}} enged, a szervezőt is beleértve, ezért elutasította a módosítást. A rendszergazda megemelheti a korlátot.", "event_duplicated": "Esemény duplikálva", "event_error": "Nem sikerült menteni az eseményt", "task_due": "Feladat határidős", diff --git a/locales/it/common.json b/locales/it/common.json index d24f86de3..07211c801 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Sei l'organizzatore", "you_attendee": "Sei un partecipante", "no_participants": "Nessun partecipante", - "limit_warning": "Più di {max} partecipanti: il server potrebbe rifiutarsi di salvare questo evento. Un amministratore può aumentare il limite.", + "limit_warning": "Più di {max, plural, one {# partecipante} other {# partecipanti}}, organizzatore incluso: il server potrebbe rifiutarsi di salvare questo evento. Un amministratore può aumentare il limite.", "count": "{count, plural, one {# partecipante} other {# partecipanti}}" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "Salva senza inviti", "rsvp_updated": "Risposta aggiornata", "rsvp_error": "Impossibile aggiornare la risposta", - "participant_limit": "Il server consente al massimo {max} partecipanti per evento, quindi ha rifiutato la modifica. Un amministratore può aumentare il limite.", + "participant_limit": "Il server consente al massimo {max, plural, one {# partecipante} other {# partecipanti}} per evento, organizzatore incluso, quindi ha rifiutato la modifica. Un amministratore può aumentare il limite.", "event_duplicated": "Evento duplicato", "event_error": "Salvataggio dell'evento non riuscito", "task_due": "Attività in scadenza", diff --git a/locales/ja/common.json b/locales/ja/common.json index bd246e114..3497b0abb 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "あなたは主催者です", "you_attendee": "あなたは参加者です", "no_participants": "参加者なし", - "limit_warning": "参加者が{max}人を超えています。サーバーがこの予定の保存を拒否する可能性があります。管理者が上限を引き上げることができます。", + "limit_warning": "参加者が主催者を含めて{max, plural, other {#人}}を超えています。サーバーがこの予定の保存を拒否する可能性があります。管理者が上限を引き上げることができます。", "count": "{count}人の参加者" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "招待状なしで保存", "rsvp_updated": "回答を更新しました", "rsvp_error": "回答の更新に失敗しました", - "participant_limit": "サーバーでは1件の予定につき参加者は{max}人までのため、この変更は拒否されました。管理者が上限を引き上げることができます。", + "participant_limit": "サーバーでは1件の予定につき参加者は主催者を含めて{max, plural, other {#人}}までのため、この変更は拒否されました。管理者が上限を引き上げることができます。", "event_duplicated": "予定を複製しました", "event_error": "予定の保存に失敗しました", "task_due": "タスクの期限です", diff --git a/locales/ko/common.json b/locales/ko/common.json index 8e1b65bf9..ca83dc784 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "내가 주최자예요", "you_attendee": "나는 참석자예요", "no_participants": "참석자가 없어요", - "limit_warning": "참석자가 {max}명을 넘어요. 서버가 이 일정의 저장을 거부할 수 있어요. 관리자가 한도를 늘릴 수 있어요.", + "limit_warning": "참석자가 주최자를 포함해 {max, plural, other {#명}}을 넘어요. 서버가 이 일정의 저장을 거부할 수 있어요. 관리자가 한도를 늘릴 수 있어요.", "count": "참석자 {count}명" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "초대장 없이 저장", "rsvp_updated": "응답이 업데이트되었어요", "rsvp_error": "응답을 업데이트하지 못했어요", - "participant_limit": "서버에서 일정당 참석자를 최대 {max}명까지만 허용해서 이 변경을 거부했어요. 관리자가 한도를 늘릴 수 있어요.", + "participant_limit": "서버에서 일정당 참석자를 주최자를 포함해 최대 {max, plural, other {#명}}까지만 허용해서 이 변경을 거부했어요. 관리자가 한도를 늘릴 수 있어요.", "event_duplicated": "일정이 복제되었어요", "event_error": "일정을 저장하지 못했어요", "task_due": "할 일 기한이 다가와요", diff --git a/locales/lv/common.json b/locales/lv/common.json index fda93629c..3bc8723e3 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2894,7 +2894,7 @@ "you_organizer": "Jūs esat organizators", "you_attendee": "Jūs esat dalībnieks", "no_participants": "Nav dalībnieku", - "limit_warning": "Vairāk nekā {max} dalībnieki: serveris var atteikties saglabāt šo pasākumu. Administrators var palielināt ierobežojumu.", + "limit_warning": "Vairāk nekā {max, plural, one {# dalībnieks} other {# dalībnieki}}, ieskaitot organizatoru: serveris var atteikties saglabāt šo pasākumu. Administrators var palielināt ierobežojumu.", "count": "{count, plural, one {# dalībnieks} other {# dalībnieki}}" }, "recurrence": { @@ -3042,7 +3042,7 @@ "save_without_invitations": "Saglabāt bez ielūgumiem", "rsvp_updated": "Atbilde atjaunināta", "rsvp_error": "Neizdevās atjaunināt atbildi", - "participant_limit": "Serveris atļauj ne vairāk kā {max} dalībniekus vienam pasākumam, tāpēc šīs izmaiņas tika noraidītas. Administrators var palielināt ierobežojumu.", + "participant_limit": "Serveris atļauj ne vairāk kā {max, plural, one {# dalībnieku} other {# dalībniekus}} vienam pasākumam, ieskaitot organizatoru, tāpēc šīs izmaiņas tika noraidītas. Administrators var palielināt ierobežojumu.", "event_duplicated": "Pasākums dublēts", "event_error": "Neizdevās saglabāt pasākumu", "task_due": "Uzdevuma termiņš", diff --git a/locales/mn/common.json b/locales/mn/common.json index a7493809a..d84ab461f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Та бол зохион байгуулагч", "you_attendee": "Та оролцогч байна", "no_participants": "Оролцогчгүй", - "limit_warning": "{max}-аас олон оролцогч: сервер энэ үйл явдлыг хадгалахаас татгалзаж магадгүй. Администратор хязгаарыг нэмэгдүүлэх боломжтой.", + "limit_warning": "Зохион байгуулагчийг оролцуулаад {max, plural, other {#}}-аас олон оролцогч: сервер энэ үйл явдлыг хадгалахаас татгалзаж магадгүй. Администратор хязгаарыг нэмэгдүүлэх боломжтой.", "count": "{count, plural, one {# оролцогч} other {# оролцогч}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Урилгагүйгээр хадгалах", "rsvp_updated": "Хариулт шинэчлэгдсэн", "rsvp_error": "Хариултыг шинэчилж чадсангүй", - "participant_limit": "Сервер нэг үйл явдалд хамгийн ихдээ {max} оролцогч зөвшөөрдөг тул энэ өөрчлөлтийг татгалзлаа. Администратор хязгаарыг нэмэгдүүлэх боломжтой.", + "participant_limit": "Сервер нэг үйл явдалд зохион байгуулагчийг оролцуулаад хамгийн ихдээ {max, plural, other {#}} оролцогч зөвшөөрдөг тул энэ өөрчлөлтийг татгалзлаа. Администратор хязгаарыг нэмэгдүүлэх боломжтой.", "event_duplicated": "Үйл явдал давхардсан", "event_error": "Үйл явдлыг хадгалж чадсангүй", "task_due": "Даалгавар дуусах хугацаа", diff --git a/locales/nb/common.json b/locales/nb/common.json index f71f0ea9a..411951670 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Du er arrangøren", "you_attendee": "Du er deltaker", "no_participants": "Ingen deltakere", - "limit_warning": "Mer enn {max} deltakere: serveren kan nekte å lagre denne hendelsen. En administrator kan øke grensen.", + "limit_warning": "Mer enn {max, plural, one {# deltaker} other {# deltakere}}, arrangøren medregnet: serveren kan nekte å lagre denne hendelsen. En administrator kan øke grensen.", "count": "{count, plural, one {# deltaker} other {# deltakere}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Lagre uten invitasjoner", "rsvp_updated": "Svaret er oppdatert", "rsvp_error": "Kunne ikke oppdatere svaret", - "participant_limit": "Serveren tillater høyst {max} deltakere per hendelse, så endringen ble avvist. En administrator kan øke grensen.", + "participant_limit": "Serveren tillater høyst {max, plural, one {# deltaker} other {# deltakere}} per hendelse, arrangøren medregnet, så endringen ble avvist. En administrator kan øke grensen.", "event_duplicated": "Hendelsen er duplisert", "event_error": "Kunne ikke lagre hendelsen", "task_due": "Oppgaven forfaller", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4de1c66d4..8bedc97e5 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Je bent de organisator", "you_attendee": "Je bent een deelnemer", "no_participants": "Geen deelnemers", - "limit_warning": "Meer dan {max} deelnemers: de server kan weigeren dit evenement op te slaan. Een beheerder kan de limiet verhogen.", + "limit_warning": "Meer dan {max, plural, one {# deelnemer} other {# deelnemers}}, de organisator meegeteld: de server kan weigeren dit evenement op te slaan. Een beheerder kan de limiet verhogen.", "count": "{count, plural, one {# deelnemer} other {# deelnemers}}" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "Opslaan zonder uitnodigingen", "rsvp_updated": "Reactie bijgewerkt", "rsvp_error": "Reactie kon niet worden bijgewerkt", - "participant_limit": "De server staat maximaal {max} deelnemers per evenement toe en heeft deze wijziging daarom geweigerd. Een beheerder kan de limiet verhogen.", + "participant_limit": "De server staat maximaal {max, plural, one {# deelnemer} other {# deelnemers}} per evenement toe, de organisator meegeteld, en heeft deze wijziging daarom geweigerd. Een beheerder kan de limiet verhogen.", "event_duplicated": "Evenement gedupliceerd", "event_error": "Evenement opslaan mislukt", "task_due": "Taak vervalt", diff --git a/locales/pl/common.json b/locales/pl/common.json index 8cd972fe0..a56bc8a7f 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Jesteś organizatorem", "you_attendee": "Jesteś uczestnikiem", "no_participants": "Brak uczestników", - "limit_warning": "Ponad {max} uczestników: serwer może odmówić zapisania tego wydarzenia. Administrator może zwiększyć limit.", + "limit_warning": "Ponad {max, plural, one {# uczestnika} few {# uczestników} many {# uczestników} other {# uczestnika}} łącznie z organizatorem: serwer może odmówić zapisania tego wydarzenia. Administrator może zwiększyć limit.", "count": "{count, plural, one {# uczestnik} other {# uczestników}}" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "Zapisz bez zaproszeń", "rsvp_updated": "Odpowiedź zaktualizowano", "rsvp_error": "Nie udało się zaktualizować odpowiedzi", - "participant_limit": "Serwer pozwala na maksymalnie {max} uczestników wydarzenia, więc odrzucił tę zmianę. Administrator może zwiększyć limit.", + "participant_limit": "Serwer pozwala na maksymalnie {max, plural, one {# uczestnika} few {# uczestników} many {# uczestników} other {# uczestnika}} wydarzenia łącznie z organizatorem, więc odrzucił tę zmianę. Administrator może zwiększyć limit.", "event_duplicated": "Wydarzenie zduplikowano", "event_error": "Nie udało się zapisać wydarzenia", "task_due": "Termin zadania", diff --git a/locales/pt/common.json b/locales/pt/common.json index e5303b5d3..378f91d3d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Você é o organizador", "you_attendee": "Você é um participante", "no_participants": "Sem participantes", - "limit_warning": "Mais de {max} participantes: o servidor pode se recusar a salvar este evento. Um administrador pode aumentar o limite.", + "limit_warning": "Mais de {max, plural, one {# participante} other {# participantes}}, incluindo o organizador: o servidor pode se recusar a salvar este evento. Um administrador pode aumentar o limite.", "count": "{count, plural, one {# participante} other {# participantes}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Guardar sem convites", "rsvp_updated": "Resposta atualizada", "rsvp_error": "Falha ao atualizar resposta", - "participant_limit": "O servidor permite no máximo {max} participantes por evento, por isso recusou esta alteração. Um administrador pode aumentar o limite.", + "participant_limit": "O servidor permite no máximo {max, plural, one {# participante} other {# participantes}} por evento, incluindo o organizador, por isso recusou esta alteração. Um administrador pode aumentar o limite.", "event_duplicated": "Evento duplicado", "event_error": "Falha ao salvar o evento", "task_due": "Tarefa vencendo", diff --git a/locales/ro/common.json b/locales/ro/common.json index b651ebedb..1c9eec3b8 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Tu ești organizatorul", "you_attendee": "Sunteți participant", "no_participants": "Nu există participanți", - "limit_warning": "Mai mult de {max} participanți: serverul poate refuza salvarea acestui eveniment. Un administrator poate mări limita.", + "limit_warning": "Mai mult de {max, plural, one {# participant} few {# participanți} other {# de participanți}}, inclusiv organizatorul: serverul poate refuza salvarea acestui eveniment. Un administrator poate mări limita.", "count": "{count, plural, one {# participant} few {# participanți} other {# de participanți}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Salvează fără invitații", "rsvp_updated": "Răspuns actualizat", "rsvp_error": "Nu s-a putut actualiza răspunsul", - "participant_limit": "Serverul permite cel mult {max} participanți per eveniment, așa că a respins această modificare. Un administrator poate mări limita.", + "participant_limit": "Serverul permite cel mult {max, plural, one {# participant} few {# participanți} other {# de participanți}} per eveniment, inclusiv organizatorul, așa că a respins această modificare. Un administrator poate mări limita.", "event_duplicated": "Eveniment duplicat", "event_error": "Nu s-a putut salva evenimentul", "task_due": "Termen de finalizare a sarcinii", diff --git a/locales/ru/common.json b/locales/ru/common.json index 977f583d1..776cb918b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Вы являетесь организатором", "you_attendee": "Вы являетесь участником", "no_participants": "Нет участников", - "limit_warning": "Больше {max} участников: сервер может отказаться сохранить это событие. Администратор может увеличить лимит.", + "limit_warning": "Больше {max, plural, one {# участника} few {# участников} many {# участников} other {# участника}}, включая организатора: сервер может отказаться сохранить это событие. Администратор может увеличить лимит.", "count": "{count, plural, one {# участник} other {# участников}}" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "Сохранить без приглашений", "rsvp_updated": "Ответ обновлён", "rsvp_error": "Не удалось обновить ответ", - "participant_limit": "Сервер допускает не более {max} участников в событии, поэтому отклонил это изменение. Администратор может увеличить лимит.", + "participant_limit": "Сервер допускает не более {max, plural, one {# участника} few {# участников} many {# участников} other {# участника}} в событии, включая организатора, поэтому отклонил это изменение. Администратор может увеличить лимит.", "event_duplicated": "Событие дублировано", "event_error": "Не удалось сохранить событие", "task_due": "Срок задачи", diff --git a/locales/sk/common.json b/locales/sk/common.json index 1467c4b7a..da3d8df18 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Ste organizátorom", "you_attendee": "Ste účastníkom", "no_participants": "Žiadni účastníci", - "limit_warning": "Viac ako {max} účastníkov: server môže odmietnuť uloženie tejto udalosti. Správca môže limit zvýšiť.", + "limit_warning": "Viac ako {max, plural, one {# účastník} few {# účastníci} many {# účastníka} other {# účastníkov}} vrátane organizátora: server môže odmietnuť uloženie tejto udalosti. Správca môže limit zvýšiť.", "count": "{count, plural, one {# účastník} other {# účastníkov}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Uložiť bez pozvánok", "rsvp_updated": "Odpoveď bola aktualizovaná", "rsvp_error": "Aktualizácia odpovede zlyhala", - "participant_limit": "Server povoľuje najviac {max} účastníkov na udalosť, preto túto zmenu odmietol. Správca môže limit zvýšiť.", + "participant_limit": "Server povoľuje najviac {max, plural, one {# účastníka} few {# účastníkov} many {# účastníka} other {# účastníkov}} na udalosť vrátane organizátora, preto túto zmenu odmietol. Správca môže limit zvýšiť.", "event_duplicated": "Udalosť bola duplikovaná", "event_error": "Uloženie udalosti zlyhalo", "task_due": "Termín úlohy", diff --git a/locales/tr/common.json b/locales/tr/common.json index 8a5912828..9589b874f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Siz organizatörsünüz", "you_attendee": "Siz bir katılımcısınız", "no_participants": "Katılımcı yok", - "limit_warning": "{max} katılımcıdan fazla: sunucu bu etkinliği kaydetmeyi reddedebilir. Bir yönetici bu sınırı yükseltebilir.", + "limit_warning": "Organizatör dahil {max, plural, one {# katılımcıdan} other {# katılımcıdan}} fazla: sunucu bu etkinliği kaydetmeyi reddedebilir. Bir yönetici bu sınırı yükseltebilir.", "count": "{count, plural, one {# katılımcı} other {# katılımcı}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "Davetsiz kaydet", "rsvp_updated": "Yanıt güncellendi", "rsvp_error": "Yanıt güncellenemedi", - "participant_limit": "Sunucu etkinlik başına en fazla {max} katılımcıya izin verdiği için bu değişikliği reddetti. Bir yönetici bu sınırı yükseltebilir.", + "participant_limit": "Sunucu etkinlik başına organizatör dahil en fazla {max, plural, one {# katılımcıya} other {# katılımcıya}} izin verdiği için bu değişikliği reddetti. Bir yönetici bu sınırı yükseltebilir.", "event_duplicated": "Etkinlik çoğaltıldı", "event_error": "Etkinlik kaydedilemedi", "task_due": "Görev süresi doldu", diff --git a/locales/uk/common.json b/locales/uk/common.json index e29d426fc..2520b0ea2 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "Ви організатор", "you_attendee": "Ви учасник", "no_participants": "Немає учасників", - "limit_warning": "Понад {max} учасників: сервер може відмовитися зберегти цю подію. Адміністратор може збільшити ліміт.", + "limit_warning": "Понад {max, plural, one {# учасника} few {# учасників} many {# учасників} other {# учасника}}, включно з організатором: сервер може відмовитися зберегти цю подію. Адміністратор може збільшити ліміт.", "count": "{count, plural, one {# учасник} few {# учасники} many {# учасників} other {# учасників}}" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "Зберегти без запрошень", "rsvp_updated": "Відповідь оновлено", "rsvp_error": "Не вдалося оновити відповідь", - "participant_limit": "Сервер дозволяє щонайбільше {max} учасників у події, тому відхилив цю зміну. Адміністратор може збільшити ліміт.", + "participant_limit": "Сервер дозволяє щонайбільше {max, plural, one {# учасника} few {# учасників} many {# учасників} other {# учасника}} у події, включно з організатором, тому відхилив цю зміну. Адміністратор може збільшити ліміт.", "event_duplicated": "Подія дублюється", "event_error": "Не вдалося зберегти подію", "task_due": "Термін виконання завдання", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 4671a7d2e..935eb7110 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "您是主辦人", "you_attendee": "您是與會者", "no_participants": "沒有參與者", - "limit_warning": "參與者超過 {max} 位:伺服器可能會拒絕儲存此活動。管理員可以提高此限制。", + "limit_warning": "參與者(含主辦人)超過 {max, plural, other {# 位}}:伺服器可能會拒絕儲存此活動。管理員可以提高此限制。", "count": "{count, plural, one {# 位參與者} other {# 位參與者}}" }, "recurrence": { @@ -3069,7 +3069,7 @@ "save_without_invitations": "不傳送邀請並儲存", "rsvp_updated": "回覆已更新", "rsvp_error": "無法更新回覆", - "participant_limit": "伺服器每個活動最多允許 {max} 位參與者,因此拒絕了這項變更。管理員可以提高此限制。", + "participant_limit": "伺服器每個活動最多允許 {max, plural, other {# 位}}參與者(含主辦人),因此拒絕了這項變更。管理員可以提高此限制。", "event_duplicated": "已建立活動副本", "event_error": "無法儲存活動", "task_due": "任務到期", diff --git a/locales/zh/common.json b/locales/zh/common.json index ff1417c59..1ee6279c1 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2895,7 +2895,7 @@ "you_organizer": "您是组织者", "you_attendee": "您是与会者", "no_participants": "没有参加者", - "limit_warning": "参与者超过 {max} 位:服务器可能会拒绝保存此活动。管理员可以提高此限制。", + "limit_warning": "参与者(含组织者)超过 {max, plural, other {# 位}}:服务器可能会拒绝保存此活动。管理员可以提高此限制。", "count": "{count, plural, one {# 位参与者} other {# 位参与者}}" }, "recurrence": { @@ -3043,7 +3043,7 @@ "save_without_invitations": "不发送邀请并保存", "rsvp_updated": "回复已更新", "rsvp_error": "无法更新回复", - "participant_limit": "服务器每个活动最多允许 {max} 位参与者,因此拒绝了此更改。管理员可以提高此限制。", + "participant_limit": "服务器每个活动最多允许 {max, plural, other {# 位}}参与者(含组织者),因此拒绝了此更改。管理员可以提高此限制。", "event_duplicated": "活动已复制", "event_error": "无法保存活动", "task_due": "任务到期", diff --git a/stores/__tests__/calendar-store-synthetic-ids.test.ts b/stores/__tests__/calendar-store-synthetic-ids.test.ts index 69136e4d4..6350045b9 100644 --- a/stores/__tests__/calendar-store-synthetic-ids.test.ts +++ b/stores/__tests__/calendar-store-synthetic-ids.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useCalendarStore, resolveMutationTarget } from '../calendar-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { CalendarEvent } from '@/lib/jmap/types'; -import { jmapError, jmapSetError } from '@/lib/jmap/jmap-error'; +import { jmapError, jmapErrorType, jmapSetError } from '@/lib/jmap/jmap-error'; // Occurrences produced by server-side recurrence expansion carry synthetic // ids (lib/recurrence-instances.ts). The store writes "this occurrence only" @@ -142,7 +142,8 @@ describe('updateEvent on an expanded occurrence', () => { await expect(useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'x' })).rejects.toThrow('single occurrence'); expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1); - expect(useCalendarStore.getState().error).toBe('Failed to update event'); + // The caller reports it, translated; a store error would toast it again. + expect(useCalendarStore.getState().error).toBeNull(); }); it('targets the base event for the single occurrence of a non-recurring event', async () => { @@ -585,10 +586,210 @@ describe('an event over the participant limit', () => { it('passes Stalwart 1.0’s refusal of a new event on to the caller', async () => { const client = fakeClient({ createCalendarEvent: vi.fn().mockRejectedValue(refusal()) }); await expect(useCalendarStore.getState().createEvent(client, { title: 'All hands' })).rejects.toThrow(/more than 20/); + // The caller says it, translated; a store error would toast it twice. + expect(useCalendarStore.getState().error).toBeNull(); }); it('still answers null for any other failure to create', async () => { const client = fakeClient({ createCalendarEvent: vi.fn().mockRejectedValue(new Error('boom')) }); await expect(useCalendarStore.getState().createEvent(client, { title: 'All hands' })).resolves.toBeNull(); + expect(useCalendarStore.getState().error).toBe('Failed to create event'); + }); +}); + +// Stalwart 1.0 shows a calendar's default alerts in place of an event's own +// while the event uses them, for its occurrences too, and refuses +// useDefaultAlerts on a single occurrence. An alert changed on one occurrence +// was stored and never shown ("Event updated", the default still there). The +// series now stops using the defaults first, keeping the alerts it shows as +// its own; then the occurrence's alerts go out. 0.16.23 stores and shows the +// occurrence's alerts either way. +describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('changing the alerts of one occurrence of a series on default alerts on Stalwart %s', (version) => { + const alert = (offset: string) => ({ + '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset, relativeTo: 'start' }, action: 'display', + }) as unknown as NonNullable[string]; + // What the series shows: the calendar's default under its id on 1.0, the + // copy 0.16 made of it when the series was created. + const shown: Record> = version === '1.0' + ? { 'default-15': alert('-PT15M') } + : { a1: alert('-PT15M') }; + // What the editor sends (buildAlertsPatch): the changed alert under an id + // of its own, and the defaults off. + const EDIT = { alerts: { alert1: alert('-PT1H') }, useDefaultAlerts: false } as Partial; + + function onDefaults(extra: Partial> = {}) { + return fakeClient({ + getCalendarEvent: vi.fn().mockResolvedValue({ id: 'b', useDefaultAlerts: true, alerts: shown }), + ...extra, + }); + } + + it('turns the defaults off for the series, keeping its alerts, then writes the occurrence', async () => { + const client = onDefaults(); + useCalendarStore.setState({ events: [occurrence({ useDefaultAlerts: true, alerts: shown })] }); + + await useCalendarStore.getState().updateEvent(client, 'maaaaab', EDIT); + + expect(client.getCalendarEvent).toHaveBeenCalledWith('b', undefined); + const [first, second] = client.updateCalendarEvent.mock.calls; + expect(first).toEqual(['b', { useDefaultAlerts: false, alerts: { alert1: alert('-PT15M') } }, false, undefined]); + expect(second[0]).toBe('maaaaab'); + expect(second[1]).toMatchObject({ alerts: { alert1: alert('-PT1H') } }); + // Refused on one occurrence by 1.0; the series carries it now. + expect(second[1]).not.toHaveProperty('useDefaultAlerts'); + }); + + it('leaves a series that no longer uses the defaults alone', async () => { + const client = onDefaults({ + getCalendarEvent: vi.fn().mockResolvedValue({ id: 'b', useDefaultAlerts: false, alerts: { alert1: alert('-PT15M') } }), + }); + useCalendarStore.setState({ events: [occurrence({ useDefaultAlerts: true, alerts: shown })] }); + + await useCalendarStore.getState().updateEvent(client, 'maaaaab', EDIT); + + expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1); + expect(client.updateCalendarEvent.mock.calls[0][0]).toBe('maaaaab'); + }); + + it('asks nothing about the series for any other change of the occurrence', async () => { + const client = onDefaults(); + useCalendarStore.setState({ events: [occurrence({ useDefaultAlerts: true, alerts: shown })] }); + + await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Renamed' }); + + expect(client.getCalendarEvent).not.toHaveBeenCalled(); + expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1); + }); +}); + +it('turns the defaults off before writing the override of an occurrence the browser expanded (0.16 before 0.16.20)', async () => { + const alert = { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' }; + const client = fakeClient({ + getCalendarEvent: vi.fn().mockResolvedValue({ id: 'b', useDefaultAlerts: true, alerts: { a1: alert } }), + }); + const expanded = occurrence({ + id: 'b:2026-09-08T10:00:00', originalId: 'b', baseEventId: undefined, useDefaultAlerts: true, + alerts: { a1: alert } as unknown as CalendarEvent['alerts'], + }); + useCalendarStore.setState({ events: [expanded] }); + + await useCalendarStore.getState().updateEvent(client, 'b:2026-09-08T10:00:00', { + alerts: { alert1: { ...alert, trigger: { ...alert.trigger, offset: '-PT1H' } } } as unknown as CalendarEvent['alerts'], + useDefaultAlerts: false, + }); + + const [first, second] = client.updateCalendarEvent.mock.calls; + expect(first).toEqual(['b', { useDefaultAlerts: false, alerts: { alert1: alert } }, false, undefined]); + expect(second[0]).toBe('b'); + expect(Object.keys(second[1])).toEqual(['recurrenceOverrides/2026-09-08T10:00:00']); +}); + +// Two range loads out at once - the view moved on - finish in either order; +// the store keeps the latest one's events, range and failure flag. +describe('range loads that finish out of order', () => { + const NEXT = ['2026-10-01T00:00:00', '2026-10-31T23:59:59'] as const; + const later = () => { + let settle!: { resolve: (v: CalendarEvent[]) => void; reject: (e: unknown) => void }; + const promise = new Promise((resolve, reject) => { settle = { resolve, reject }; }); + return { promise, ...settle }; + }; + + it('drops an earlier load that fails after the latest one succeeded on Stalwart 1.0', async () => { + const client = fakeClient(); + const first = later(); + client.queryAllCalendarEvents.mockReturnValueOnce(first.promise).mockResolvedValueOnce([occurrence({ title: 'October' })]); + + const loading = useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]); + await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]); + first.reject(new Error('Store unavailable')); + await loading; + + const state = useCalendarStore.getState(); + expect(state.dateRange).toEqual({ start: NEXT[0], end: NEXT[1] }); + expect(state.eventsLoadError).toBe(false); + expect(state.events.map((e) => e.title)).toEqual(['October']); + }); + + it('drops an earlier load that succeeds after the latest one failed on Stalwart 1.0', async () => { + const client = fakeClient(); + const first = later(); + client.queryAllCalendarEvents.mockReturnValueOnce(first.promise).mockRejectedValueOnce(new Error('Store unavailable')); + + const loading = useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]); + await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]); + first.resolve([occurrence({ title: 'September' })]); + await loading; + + const state = useCalendarStore.getState(); + expect(state.dateRange).toEqual({ start: NEXT[0], end: NEXT[1] }); + expect(state.eventsLoadError).toBe(true); + expect(state.events).toEqual([]); + }); + + it('drops a load of every account that finishes after a later one', async () => { + const client = fakeClient(); + const first = later(); + client.queryAllCalendarEvents.mockReturnValueOnce(first.promise).mockResolvedValueOnce([occurrence({ title: 'October' })]); + const accounts = [{ localAccountId: 'A@h', client }]; + + const loading = useCalendarStore.getState().fetchAllAccountsEvents(accounts, RANGE[0], RANGE[1]); + await useCalendarStore.getState().fetchAllAccountsEvents(accounts, NEXT[0], NEXT[1]); + first.resolve([occurrence({ title: 'September' })]); + await loading; + + expect(useCalendarStore.getState().dateRange).toEqual({ start: NEXT[0], end: NEXT[1] }); + expect(useCalendarStore.getState().events.map((e) => e.title)).toEqual(['October']); + }); + + it('drops a load for the account the user left', async () => { + const client = fakeClient(); + const first = later(); + client.queryAllCalendarEvents.mockReturnValueOnce(first.promise); + + const loading = useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]); + useCalendarStore.getState().clearState(); + first.resolve([occurrence()]); + await loading; + + expect(useCalendarStore.getState().events).toEqual([]); + expect(useCalendarStore.getState().dateRange).toBeNull(); + }); +}); + +// An invitation answered or added from the mail view is imported first. +// When Stalwart refuses it, the banner has to say why - the participant +// limit on 1.0 above all. +describe('importEvents and the server refusing an event', () => { + const LIMIT = { + type: 'invalidProperties', description: 'An event cannot have more than 20 participants.', properties: ['participants'], + }; + + function refusing() { + return fakeClient({ + getCalendarEvents: vi.fn().mockResolvedValue([]), + batchCreateCalendarEvents: vi.fn().mockResolvedValue({ created: [], failed: ['e0'], notCreated: { e0: LIMIT } }), + }); + } + + it('rejects with the refusal when asked to and nothing was imported, on Stalwart 1.0', async () => { + useCalendarStore.setState({ calendars: [{ id: 'cal', name: 'Personal' } as never] }); + + const error = await useCalendarStore.getState() + .importEvents(refusing(), [{ uid: 'u1', title: 'All hands', start: '2026-10-12T10:00:00' }], 'cal', { throwIfNoneImported: true }) + .then(() => null, (e: unknown) => e); + + expect(jmapErrorType(error)).toBe('invalidProperties'); + expect((error as Error).message).toBe(LIMIT.description); + expect(useCalendarStore.getState().error).toBeNull(); + }); + + it('still counts for the import dialog, and keeps its error for it', async () => { + useCalendarStore.setState({ calendars: [{ id: 'cal', name: 'Personal' } as never] }); + + const count = await useCalendarStore.getState() + .importEvents(refusing(), [{ uid: 'u1', title: 'All hands', start: '2026-10-12T10:00:00' }], 'cal'); + + expect(count).toBe(0); + expect(useCalendarStore.getState().error).toBe(`1 event(s) could not be imported: ${LIMIT.description}`); }); }); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 2f1be34e0..3a2d70d83 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -10,6 +10,8 @@ import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normaliz import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { SchedulingDeniedError } from '@/lib/jmap/scheduling-error'; import { participantLimitOf } from '@/lib/calendar-participants'; +import { withOwnAlertIds } from '@/lib/calendar-alerts'; +import { jmapSetError } from '@/lib/jmap/jmap-error'; import { baseEventStoreId, buildFallbackExcludePatch, @@ -211,6 +213,31 @@ async function updateOccurrence( await updateBaseEvent(client, instance.baseEventId, fallback, sendSchedulingMessages, targetAccountId); } +/** + * Turn the calendar's default alerts off for a whole series, keeping the + * alerts it shows as its own - before one occurrence's alerts change in a + * way that turns them off (buildAlertsPatch). While a series uses them, + * Stalwart 1.0 shows the calendar's defaults in place of any occurrence's + * own alerts, and refuses useDefaultAlerts on a single occurrence ("This + * property cannot be modified on a single occurrence."): the occurrence's + * change was stored and never shown. 0.16 stores and shows it either way; + * its series keeps the same alerts. + */ +async function stopSeriesDefaultAlerts( + client: IJMAPClient, + baseId: string, + targetAccountId: string | undefined, +): Promise { + const base = await client.getCalendarEvent(baseId, targetAccountId); + if (!base?.useDefaultAlerts) return; + const shown = base.alerts ?? {}; + await client.updateCalendarEvent(baseId, { + useDefaultAlerts: false, + // Under ids of their own: 1.0 drops alerts saved under a default alert's id. + alerts: Object.keys(shown).length > 0 ? withOwnAlertIds(Object.values(shown), Object.keys(shown)) : null, + }, false, targetAccountId); +} + /** Destroy one expanded occurrence; falls back to excluding it on the base event like `updateOccurrence`. */ async function destroyOccurrence( client: IJMAPClient, @@ -294,6 +321,11 @@ async function destroyBrowserOccurrence( let refetchVisibleRange: (() => Promise) | null = null; let refetchVisibleRangeInFlight: Promise | null = null; +// Range loads overlap when the view moves on while one is still out. Only the +// latest writes the store - its events, its range and whether it failed; an +// earlier one that finishes after it, well or not, is dropped. +let rangeLoadSeq = 0; + async function refetchAfterOccurrenceMutation(): Promise { if (!refetchVisibleRange) return; if (!refetchVisibleRangeInFlight) { @@ -552,7 +584,18 @@ interface CalendarStore { updateEvent: (client: IJMAPClient, id: string, updates: Partial, sendSchedulingMessages?: boolean) => Promise; deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise; rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record | null, scope?: 'occurrence' | 'series') => Promise; - importEvents: (client: IJMAPClient, events: Partial[], calendarId: string) => Promise; + /** + * Create `events` in the calendar, linking those whose UID the account + * already has; resolves to how many were created or linked. With + * `throwIfNoneImported`, rejects with the server's first refusal (its + * SetError type kept) when none was. + */ + importEvents: ( + client: IJMAPClient, + events: Partial[], + calendarId: string, + options?: { throwIfNoneImported?: boolean }, + ) => Promise; updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial) => Promise; setDefaultCalendar: (client: IJMAPClient, calendarId: string) => Promise; shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise; @@ -647,12 +690,14 @@ export const useCalendarStore = create()( fetchEvents: async (client, start, end, options) => { refetchVisibleRange = () => get().fetchEvents(client, start, end, { silent: true }); + const seq = ++rangeLoadSeq; set(options?.silent ? { error: null } : { isLoadingEvents: true, error: null }); try { const rawEvents = await client.queryAllCalendarEvents({ after: start, before: end, }); + if (seq !== rangeLoadSeq) return; // Filter out malformed events missing required 'start' field, or // whose start string fails to parse (would otherwise crash format() // calls in the rendering path - #316). @@ -678,10 +723,11 @@ export const useCalendarStore = create()( } set({ events, isLoadingEvents: false, eventsLoadError: false, dateRange: { start, end } }); } catch (error) { + debug.error('Failed to fetch events:', error); + if (seq !== rangeLoadSeq) return; // Keep the events already on screen - an empty grid would read as // "no events" - and take the range as asked for, so a push or a // refresh reloads it rather than the one loaded before. - debug.error('Failed to fetch events:', error); set({ eventsLoadError: true, isLoadingEvents: false, dateRange: { start, end } }); } }, @@ -716,6 +762,7 @@ export const useCalendarStore = create()( fetchAllAccountsEvents: async (accounts, start, end, options) => { refetchVisibleRange = () => get().fetchAllAccountsEvents(accounts, start, end, { silent: true }); + const seq = ++rangeLoadSeq; set(options?.silent ? { error: null } : { isLoadingEvents: true, error: null }); try { let failed = false; @@ -736,6 +783,7 @@ export const useCalendarStore = create()( } }), ); + if (seq !== rangeLoadSeq) return; set({ events: results.flat(), isLoadingEvents: false, @@ -744,6 +792,7 @@ export const useCalendarStore = create()( }); } catch (error) { debug.error('Failed to fetch all-account events:', error); + if (seq !== rangeLoadSeq) return; set({ eventsLoadError: true, isLoadingEvents: false, dateRange: { start, end } }); } }, @@ -832,10 +881,10 @@ export const useCalendarStore = create()( return mappedCreated; } catch (error) { debug.error('Failed to create event:', error); - set({ error: 'Failed to create event' }); // The caller can offer to save it without the invitations, or say - // which limit the server holds it to. + // which limit the server holds it to - and says so itself. if (error instanceof SchedulingDeniedError || participantLimitOf(error) !== null) throw error; + set({ error: 'Failed to create event' }); return null; } }, @@ -866,13 +915,19 @@ export const useCalendarStore = create()( } cleanUpdates.calendarIds = remapped; } + // An occurrence's alerts that turn the calendar's defaults off. + const stopsDefaultAlerts = cleanUpdates.useDefaultAlerts === false && 'alerts' in cleanUpdates; if (target.isOccurrence && storeEvent) { + if (stopsDefaultAlerts && storeEvent.baseEventId) { + await stopSeriesDefaultAlerts(client, storeEvent.baseEventId, targetAccountId); + } await updateOccurrence( client, storeEvent, realId, (options) => withNewOverrideDetails(storeEvent, cleanUpdates, options), sendSchedulingMessages, targetAccountId, ); } else if (target.isBrowserOccurrence) { + if (stopsDefaultAlerts) await stopSeriesDefaultAlerts(client, realId, targetAccountId); await updateBrowserOccurrence(client, target, cleanUpdates, sendSchedulingMessages); } else { await updateBaseEvent(client, realId, cleanUpdates, sendSchedulingMessages, targetAccountId); @@ -912,8 +967,9 @@ export const useCalendarStore = create()( // `sendSchedulingMessages` argument already passed above - a manual // iMIP send here produced duplicate emails. } catch (error) { + // Every caller says what went wrong, in the user's language (see + // useCalendarErrorMessage); a store error would toast it twice. debug.error('Failed to update event:', error); - set({ error: 'Failed to update event' }); throw error; } }, @@ -986,7 +1042,7 @@ export const useCalendarStore = create()( } }, - importEvents: async (client, events, calendarId) => { + importEvents: async (client, events, calendarId, options) => { // Resolve shared calendar IDs const cal = get().calendars.find(c => c.id === calendarId); const realCalendarId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); @@ -1148,6 +1204,9 @@ export const useCalendarStore = create()( // Batch create in chunks of 50 to avoid oversized requests const BATCH_SIZE = 50; let imported = 0; + // The first refusal, for a caller that asked to be told. + let refusal: unknown = null; + let failedMessage: string | null = null; for (let i = 0; i < prepared.length; i += BATCH_SIZE) { const batch = prepared.slice(i, i + BATCH_SIZE); try { @@ -1157,12 +1216,16 @@ export const useCalendarStore = create()( const first = Object.values(notCreated)[0]; const reason = first?.description || first?.type || 'unknown error'; debug.warn('calendar', `Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed (${reason})`); - set({ error: `${failed.length} event(s) could not be imported: ${reason}` }); + refusal ??= jmapSetError(first?.description || 'Failed to import event', first); + failedMessage = `${failed.length} event(s) could not be imported: ${reason}`; } } catch (error) { debug.error(`Import batch ${i / BATCH_SIZE + 1} failed:`, error); + refusal ??= error; } } + if (options?.throwIfNoneImported && imported + linked === 0 && refusal) throw refusal; + if (failedMessage) set({ error: failedMessage }); // Re-fetch all events properly so the store state is consistent // (with recurrence expansion, multi-account mapping, etc.) @@ -1692,6 +1755,8 @@ export const useCalendarStore = create()( clearState: () => { refetchVisibleRange = null; + // A load still out for the account being left must not land. + rangeLoadSeq++; // Preserve iCal subscriptions across the account-switch teardown. // They're now scoped per-account via sub.accountId - wiping them // here would lose them from localStorage on every switch.