mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: keep occurrence alert edits on 1.0 and count the organizer
Fixes from a second review of the Stalwart 1.0 calendar work.
- Changing the alerts of one occurrence of a series that uses the
calendar's default alerts said "Event updated" and kept the default
on 1.0: while a series uses them, 1.0 shows the defaults in place of
an occurrence's own alerts and refuses `useDefaultAlerts` on a single
occurrence. The series now stops using the defaults first, keeping
the alerts it shows under ids of their own, and then the occurrence's
alerts go out. 0.16.23 showed the edit already and still does.
- The participant limit counts the organizer entry a save adds (live on
1.0: 19 attendees and the organizer are accepted, 20 are refused), so
the editor warned one attendee late. It warns at the limit now, and
the warning and the refusal say the organizer counts, with plural
forms in every locale.
- Adding or answering an invitation from the mail view, and applying a
proposal, said "could not complete" when the server refused the
event: the import swallowed its SetError. The banner now asks the
import to reject with the refusal and names the participant limit
like the calendar does.
- A range load that finished after a later one could put back its own
events, range or failure. Only the latest load writes the store, and
a load for an account the user left is dropped.
- A search filter with an OR, a NOT or a condition of several keys next
to its date bounds goes out as it is instead of being expanded, which
1.0 refuses.
- A time zone setting typed in another letter case ("europe/berlin")
is used as Intl spells it. 1.0 refused it as a query argument, and
0.16 and 1.0 both dropped it from new events, which were stored
floating.
- A failed save no longer shows the store's English toast next to the
translated message.
- Tests for a limited read that asks later chunks of a request again
after a refused chunk or a full page.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 && (
|
||||
<p role="status" className="mt-1.5 flex items-start gap-1.5 text-xs text-warning">
|
||||
<AlertTriangle className="mt-px h-3.5 w-3.5 flex-shrink-0" />
|
||||
{t("participants.limit_warning", { max: maxParticipants })}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
// 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(<CalendarInvitationBanner email={makeEmail({ id: 'email-9' })} />);
|
||||
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(<CalendarInvitationBanner email={makeEmail({ id: 'email-10' })} />);
|
||||
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(<CalendarInvitationBanner email={makeEmail({
|
||||
id: 'email-11',
|
||||
attachments: [{ partId: '1', blobId: 'blob-11', size: 256, type: 'text/calendar; method=COUNTER', name: 'counter.ics' }],
|
||||
})} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>]>([
|
||||
['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<typeof vi.spyOn>;
|
||||
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<number, Array<{ after: string; before: string; position: unknown }>>();
|
||||
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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
+11
-4
@@ -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) => {
|
||||
|
||||
+27
-5
@@ -63,15 +63,37 @@ export function isValidTimeZone(tz: unknown): tz is string {
|
||||
return valid;
|
||||
}
|
||||
|
||||
const canonicalCache = new Map<string, string | null>();
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
|
||||
@@ -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": "موعد استحقاق المهمة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "موعد وظیفه",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "משימה בשל",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "タスクの期限です",
|
||||
|
||||
@@ -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": "할 일 기한이 다가와요",
|
||||
|
||||
@@ -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ņš",
|
||||
|
||||
@@ -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": "Даалгавар дуусах хугацаа",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Срок задачи",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Термін виконання завдання",
|
||||
|
||||
@@ -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": "任務到期",
|
||||
|
||||
@@ -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": "任务到期",
|
||||
|
||||
@@ -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<CalendarEvent['alerts']>[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<string, ReturnType<typeof alert>> = 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<CalendarEvent>;
|
||||
|
||||
function onDefaults(extra: Partial<Record<keyof IJMAPClient, unknown>> = {}) {
|
||||
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<CalendarEvent[]>((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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<void>) | null = null;
|
||||
let refetchVisibleRangeInFlight: Promise<void> | 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<void> {
|
||||
if (!refetchVisibleRange) return;
|
||||
if (!refetchVisibleRangeInFlight) {
|
||||
@@ -552,7 +584,18 @@ interface CalendarStore {
|
||||
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series') => Promise<void>;
|
||||
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
/**
|
||||
* 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<CalendarEvent>[],
|
||||
calendarId: string,
|
||||
options?: { throwIfNoneImported?: boolean },
|
||||
) => Promise<number>;
|
||||
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
|
||||
setDefaultCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
|
||||
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
|
||||
@@ -647,12 +690,14 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
|
||||
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<CalendarStore>()(
|
||||
}
|
||||
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<CalendarStore>()(
|
||||
|
||||
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<CalendarStore>()(
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (seq !== rangeLoadSeq) return;
|
||||
set({
|
||||
events: results.flat(),
|
||||
isLoadingEvents: false,
|
||||
@@ -744,6 +792,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
});
|
||||
} 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<CalendarStore>()(
|
||||
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<CalendarStore>()(
|
||||
}
|
||||
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<CalendarStore>()(
|
||||
// `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<CalendarStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<CalendarStore>()(
|
||||
// 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<CalendarStore>()(
|
||||
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<CalendarStore>()(
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user