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.