From 1cbf763a36210fa751e9c357b1d25e0487cb3b11 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:30:33 +0200 Subject: [PATCH] fix: send calendar reminders in the app language Reminder notifications are worded when they are scheduled, outside React, and were always English ("Starts in 15 min", "(No title)", "Task due"). They now come from the catalog through the locale store, with ICU plurals for the minutes, hours and days. Each scheduled reminder records its language, so switching languages rewords the pending ones, and the Android channel ("Calendar reminders", shown in the system settings) is named and renamed in the app language. The reminder labels picked the English form whenever a catalog entry was an ICU plural, which is all of them; they now pass the count to t(), and "minutes after" is one plural message instead of a number glued to a fragment. The import refusal error, the exported alarm text and the "sharing is not available" error are translated too. --- locales/rn/en.json | 8 ++++ .../calendar-alert-scheduler.test.ts | 20 ++++++++- .../calendar-alerts-preserved.test.ts | 12 +++--- src/lib/__tests__/calendar-alerts.test.ts | 10 +++++ .../__tests__/calendar-notifications.test.ts | 23 ++++++++++ src/lib/calendar-alert-scheduler.ts | 36 ++++++++++------ src/lib/calendar-alerts.ts | 42 ++++++++++--------- src/lib/calendar-ics-export.ts | 6 ++- src/lib/calendar-notifications.ts | 38 +++++++++++++---- src/stores/calendar-store.ts | 18 ++++---- 10 files changed, 157 insertions(+), 56 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index 1366b4ac..a0993041 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -7,6 +7,7 @@ }, "calendar": { "alerts": { + "minutes_after": "{count, plural, one {# minute after} other {# minutes after}}", "unit_minutes_after": "minutes after" }, "colors": { @@ -41,6 +42,7 @@ "picker_unavailable": "The file picker is not installed in this build. Rebuild the app to enable importing.", "picker_unavailable_title": "Import unavailable", "refused_count": "{count, plural, one {# event could not be imported:} other {# events could not be imported:}}", + "refused_message": "{count, plural, =1 {The event could not be imported: {reason}} one {# event could not be imported: {reason}} other {# events could not be imported: {reason}}}", "selected_count": "{count} selected" }, "invitation": { @@ -76,6 +78,12 @@ "edit_calendar": "Edit calendar", "new_calendar": "New calendar" }, + "notifications": { + "channel_name": "Calendar reminders", + "starts_in_days": "{count, plural, one {Starts in # day} other {Starts in # days}}", + "starts_in_hours": "{count, plural, one {Starts in # hour} other {Starts in # hours}}", + "starts_in_minutes": "{count, plural, one {Starts in # minute} other {Starts in # minutes}}" + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {occurrence} other {occurrences}}", "every_count_days": "{count, plural, one {Every # day} other {Every # days}}", diff --git a/src/lib/__tests__/calendar-alert-scheduler.test.ts b/src/lib/__tests__/calendar-alert-scheduler.test.ts index 3e7928fd..6147cf08 100644 --- a/src/lib/__tests__/calendar-alert-scheduler.test.ts +++ b/src/lib/__tests__/calendar-alert-scheduler.test.ts @@ -99,8 +99,26 @@ describe('getUpcomingAlerts', () => { const alerts = getUpcomingAlerts([event, cancelled, acknowledged], [task, doneTask], [], { now, horizonMs: 24 * HOUR }); expect(alerts.map((a) => a.eventId + '/' + a.alertId)).toEqual(['ev1/a1', 'ev1/a2', 't1/x']); expect(alerts[0].key).toBe(`ev1:a1:${new Date('2026-03-01T08:45:00Z').getTime()}`); - expect(alerts[0].body).toBe('Starts in 15 min'); + expect(alerts[0].body).toBe('Starts in 15 minutes'); expect(alerts[2].kind).toBe('task'); + expect(alerts[2].body).toBe('Task due'); + }); + + it('words the reminders through the translator it is given', () => { + const untitled = { ...event, title: '' }; + const task = { + ...untitled, + id: 't1', + due: '2026-03-01T12:00:00Z', + alerts: { x: { trigger: { '@type': 'OffsetTrigger' as const, offset: 'PT0S' }, action: 'display' as const } }, + }; + const t = (key: string, _fallback: string, params?: Record) => + params ? `${key}(${params.count})` : key; + const [first, , due] = getUpcomingAlerts([untitled], [task], [], { now, horizonMs: 24 * HOUR }, t); + expect(first.title).toBe('calendar.events.no_title'); + expect(first.body).toBe('calendar.notifications.starts_in_minutes(15)'); + expect(due.title).toBe('calendar.tasks.no_title'); + expect(due.body).toBe('calendar.notifications.task_due'); }); it('drops alerts already in the past or beyond the horizon and honours the limit', () => { diff --git a/src/lib/__tests__/calendar-alerts-preserved.test.ts b/src/lib/__tests__/calendar-alerts-preserved.test.ts index 9dd0b99e..39706ffb 100644 --- a/src/lib/__tests__/calendar-alerts-preserved.test.ts +++ b/src/lib/__tests__/calendar-alerts-preserved.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { formatReminder, preservedAlerts, remindersToAlerts } from '../calendar-alerts'; +import { formatMessage, type MessageParams } from '../../i18n/format'; describe('preservedAlerts / remindersToAlerts', () => { const alerts = { @@ -38,12 +39,13 @@ describe('formatReminder', () => { expect(formatReminder(60 * 24 * 14)).toBe('2 weeks before'); }); - it('uses a plain translated string and ignores ICU plural templates it cannot expand', () => { - const t = (key: string, fallback?: string) => - key === 'calendar.alerts.hours_before' ? '# Stunden vorher' - : key === 'calendar.alerts.minutes_before' ? '{count, plural, one {# minute} other {# minutes}}' - : fallback ?? key; + it('hands the count to the translation, which picks the plural form', () => { + const t = (key: string, fallback?: string, params?: MessageParams) => + key === 'calendar.alerts.hours_before' + ? formatMessage('{count, plural, one {# Stunde vorher} other {# Stunden vorher}}', params, 'de') + : formatMessage(fallback ?? key, params, 'en'); expect(formatReminder(120, t)).toBe('2 Stunden vorher'); + expect(formatReminder(60, t)).toBe('1 Stunde vorher'); expect(formatReminder(5, t)).toBe('5 minutes before'); }); }); diff --git a/src/lib/__tests__/calendar-alerts.test.ts b/src/lib/__tests__/calendar-alerts.test.ts index 034609df..fba9b6d3 100644 --- a/src/lib/__tests__/calendar-alerts.test.ts +++ b/src/lib/__tests__/calendar-alerts.test.ts @@ -6,6 +6,7 @@ import { remindersToAlerts, formatReminder, } from '../calendar-alerts'; +import { translate, type MessageParams } from '../../i18n'; describe('calendar-alerts', () => { describe('offsetToMinutesBefore', () => { @@ -75,6 +76,15 @@ describe('calendar-alerts', () => { expect(formatReminder(60)).toBe('1 hour before'); expect(formatReminder(60 * 24)).toBe('1 day before'); expect(formatReminder(60 * 24 * 7)).toBe('1 week before'); + expect(formatReminder(-1)).toBe('1 minute after'); + expect(formatReminder(-30)).toBe('30 minutes after'); + }); + + it('uses the catalog plurals of the app language', () => { + const de = (key: string, fallback?: string, params?: MessageParams) => translate('de', key, fallback, params); + expect(formatReminder(1, de)).toBe(translate('de', 'calendar.alerts.minutes_before', '', { count: 1 })); + expect(formatReminder(120, de)).toBe(translate('de', 'calendar.alerts.hours_before', '', { count: 2 })); + expect(formatReminder(120, de)).not.toContain('hours'); }); }); }); diff --git a/src/lib/__tests__/calendar-notifications.test.ts b/src/lib/__tests__/calendar-notifications.test.ts index a0cba412..e678dbbc 100644 --- a/src/lib/__tests__/calendar-notifications.test.ts +++ b/src/lib/__tests__/calendar-notifications.test.ts @@ -146,6 +146,28 @@ describe('calendar reminders (B17)', () => { expect(N.scheduleNotificationAsync).not.toHaveBeenCalled(); }); + it('words reminders in the app language and rewords them after a switch', async () => { + const { useLocaleStore } = await import('../../stores/locale-store'); + await mod.rescheduleCalendarNotifications(); + + let request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0]; + expect(request.content.body).toBe('Starts in 15 minutes'); + expect(request.content.data).toMatchObject({ key: upcomingKey, locale: 'en' }); + expect(vi.mocked(N.setNotificationChannelAsync).mock.calls[0][1]).toMatchObject({ name: 'Calendar reminders' }); + + // Pending in English; the app is now in German. + h.pending = [{ identifier: 'en-1', content: { data: { tag: TAG, key: upcomingKey, locale: 'en' } } }]; + vi.mocked(N.scheduleNotificationAsync).mockClear(); + useLocaleStore.setState({ locale: 'de' }); + await mod.rescheduleCalendarNotifications(); + + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('en-1'); + request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0]; + expect(request.content.data).toMatchObject({ key: upcomingKey, locale: 'de' }); + // The channel is renamed too. + expect(N.setNotificationChannelAsync).toHaveBeenCalledTimes(2); + }); + it('keeps the scheduled reminders when the upcoming events cannot be loaded', async () => { h.pending = [reminder('keep', upcomingKey)]; h.loadEventsInRange.mockRejectedValue(new Error('offline')); @@ -244,6 +266,7 @@ describe('tapping a calendar reminder', () => { expect(request.content.data).toEqual({ tag: TAG, key: expect.any(String), + locale: 'en', eventId: 'acc-2:ev1:2026-09-24T10:00:00Z', kind: 'event', serverId: 'ev1', diff --git a/src/lib/calendar-alert-scheduler.ts b/src/lib/calendar-alert-scheduler.ts index fee59fbb..a224e7c4 100644 --- a/src/lib/calendar-alert-scheduler.ts +++ b/src/lib/calendar-alert-scheduler.ts @@ -1,5 +1,6 @@ import { parseISO } from 'date-fns'; import type { Alert, Calendar, CalendarEvent } from '../api/types'; +import { formatMessage, type MessageParams } from '../i18n/format'; import { getTaskDueDate, parseDuration } from './calendar-utils'; import { seriesIdOf, stableOccurrenceKey } from './recurrence-instances'; @@ -113,29 +114,38 @@ export interface AlertWindow { limit?: number; } -function formatWhen(fireTimeMs: number, startMs: number): string { +/** Translates a reminder's texts; the notifications pass the app's t(). */ +export type AlertTranslate = (key: string, fallback: string, params?: MessageParams) => string; + +// Without a translator: the English text. +const english: AlertTranslate = (_key, fallback, params) => formatMessage(fallback, params, 'en'); + +/** "Starts in 15 minutes" for a reminder firing ahead of the start. */ +function startsIn(fireTimeMs: number, startMs: number, t: AlertTranslate): string { const diffMin = Math.round((startMs - fireTimeMs) / 60000); - if (diffMin <= 0) return 'now'; - if (diffMin < 60) return `in ${diffMin} min`; - if (diffMin < 24 * 60) { - const h = Math.round(diffMin / 60); - return `in ${h} h`; + if (diffMin <= 0) return t('calendar.notifications.alert_now', 'Starting now'); + if (diffMin < 60) { + return t('calendar.notifications.starts_in_minutes', '{count, plural, one {Starts in # minute} other {Starts in # minutes}}', { count: diffMin }); } - const d = Math.round(diffMin / (24 * 60)); - return `in ${d} d`; + if (diffMin < 24 * 60) { + return t('calendar.notifications.starts_in_hours', '{count, plural, one {Starts in # hour} other {Starts in # hours}}', { count: Math.round(diffMin / 60) }); + } + return t('calendar.notifications.starts_in_days', '{count, plural, one {Starts in # day} other {Starts in # days}}', { count: Math.round(diffMin / (24 * 60)) }); } /** * Every display alert of the given events/tasks that fires inside the * window, soonest first. Cancelled events, completed tasks and acknowledged * alerts are skipped (#572). Expanded occurrences carry their own utcStart, - * so each occurrence yields its own alert. + * so each occurrence yields its own alert. Titles and bodies are in the + * language of `t` (English without it). */ export function getUpcomingAlerts( events: CalendarEvent[], tasks: CalendarEvent[], calendars: Calendar[], window: AlertWindow, + t: AlertTranslate = english, ): ScheduledAlert[] { const out: ScheduledAlert[] = []; const until = window.now + window.horizonMs; @@ -156,8 +166,8 @@ export function getUpcomingAlerts( eventId: event.id, alertId, fireTimeMs, - title: event.title || '(No title)', - body: Number.isNaN(startMs) ? '' : `Starts ${formatWhen(fireTimeMs, startMs)}`, + title: event.title || t('calendar.events.no_title', '(No title)'), + body: Number.isNaN(startMs) ? '' : startsIn(fireTimeMs, startMs, t), kind: 'event', serverId: seriesIdOf(event), accountId: event.accountId, @@ -181,8 +191,8 @@ export function getUpcomingAlerts( eventId: task.id, alertId, fireTimeMs, - title: task.title || '(No title)', - body: 'Task due', + title: task.title || t('calendar.tasks.no_title', '(No title)'), + body: t('calendar.notifications.task_due', 'Task due'), kind: 'task', serverId: task.originalId ?? task.id, accountId: task.accountId, diff --git a/src/lib/calendar-alerts.ts b/src/lib/calendar-alerts.ts index d3c94e17..87c47c21 100644 --- a/src/lib/calendar-alerts.ts +++ b/src/lib/calendar-alerts.ts @@ -1,4 +1,5 @@ import type { Alert, CalendarEvent } from '../api/types'; +import { formatMessage, type MessageParams } from '../i18n/format'; // A reminder, expressed as "minutes before the event start". 0 means "at the // time of the event". Negative values (reminder *after* start) are preserved @@ -106,37 +107,38 @@ export function remindersToAlerts( return alerts; } -type Translate = (key: string, fallback?: string) => string; -const plain: Translate = (_key, fallback) => fallback ?? _key; - -function plural(t: Translate, count: number, key: string, one: string, many: string): string { - // Locale files carry ICU plurals ("{count, plural, one {# minute before} other {# minutes before}}") - // that the mobile t() can't expand, so pick the English-style form and only - // use the translation when it is a plain string. - const raw = t(key, ''); - if (raw && !raw.includes('{')) return raw.replace('#', String(count)); - return (count === 1 ? one : many).replace('#', String(count)); -} +type Translate = (key: string, fallback?: string, params?: MessageParams) => string; +// Without a translator: the English text, plurals resolved. +const plain: Translate = (key, fallback, params) => formatMessage(fallback ?? key, params, 'en'); +/** "15 minutes before", "1 day before", "At time of event" in the language of `t`. */ export function formatReminder(minutesBefore: number, t: Translate = plain): string { + const minutes = (count: number) => + t('calendar.alerts.minutes_before', '{count, plural, one {# minute before} other {# minutes before}}', { count }); + const hours = (count: number) => + t('calendar.alerts.hours_before', '{count, plural, one {# hour before} other {# hours before}}', { count }); + const days = (count: number) => + t('calendar.alerts.days_before', '{count, plural, one {# day before} other {# days before}}', { count }); + const weeks = (count: number) => + t('calendar.alerts.weeks_before', '{count, plural, one {# week before} other {# weeks before}}', { count }); + if (minutesBefore === 0) return t('calendar.alerts.at_time', 'At time of event'); - if (minutesBefore < 0) return `${Math.abs(minutesBefore)} ${t('calendar.alerts.unit_minutes_after', 'minutes after')}`; - if (minutesBefore < 60) { - return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before'); + if (minutesBefore < 0) { + return t('calendar.alerts.minutes_after', '{count, plural, one {# minute after} other {# minutes after}}', { + count: Math.abs(minutesBefore), + }); } + if (minutesBefore < 60) return minutes(minutesBefore); if (minutesBefore < 60 * 24) { const h = minutesBefore / 60; - if (Number.isInteger(h)) return plural(t, h, 'calendar.alerts.hours_before', '# hour before', '# hours before'); - return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before'); + return Number.isInteger(h) ? hours(h) : minutes(minutesBefore); } if (minutesBefore < 60 * 24 * 7) { const d = minutesBefore / (60 * 24); - if (Number.isInteger(d)) return plural(t, d, 'calendar.alerts.days_before', '# day before', '# days before'); - return plural(t, Math.round(minutesBefore / 60), 'calendar.alerts.hours_before', '# hour before', '# hours before'); + return Number.isInteger(d) ? days(d) : hours(Math.round(minutesBefore / 60)); } const w = minutesBefore / (60 * 24 * 7); - if (Number.isInteger(w)) return plural(t, w, 'calendar.alerts.weeks_before', '# week before', '# weeks before'); - return plural(t, Math.round(minutesBefore / (60 * 24)), 'calendar.alerts.days_before', '# day before', '# days before'); + return Number.isInteger(w) ? weeks(w) : days(Math.round(minutesBefore / (60 * 24))); } export type ReminderUnit = 'minutes' | 'hours' | 'days' | 'weeks'; diff --git a/src/lib/calendar-ics-export.ts b/src/lib/calendar-ics-export.ts index 29699ad1..2d29a5d3 100644 --- a/src/lib/calendar-ics-export.ts +++ b/src/lib/calendar-ics-export.ts @@ -1,4 +1,5 @@ import type { CalendarEvent } from '../api/types'; +import { t } from '../stores/locale-store'; // Port of the webmail's lib/calendar-ics-export.ts: serialise one event as // an RFC 5545 VCALENDAR so it can be shared as a .ics file. @@ -91,7 +92,8 @@ function pushAlerts(lines: string[], event: CalendarEvent): void { } else if (trigger['@type'] === 'AbsoluteTrigger' && trigger.when) { lines.push(`TRIGGER;VALUE=DATE-TIME:${stripDateSeparators(trigger.when)}`); } - lines.push(`DESCRIPTION:${escapeText(event.title || 'Reminder')}`); + // Other calendar apps show this when the alarm goes off. + lines.push(`DESCRIPTION:${escapeText(event.title || t('calendar.tasks.alert', 'Reminder'))}`); lines.push('END:VALARM'); } } @@ -204,7 +206,7 @@ export async function shareEventICS(event: CalendarEvent): Promise { if (file.exists) file.delete(); file.write(eventToICS(event)); if (!(await Sharing.isAvailableAsync())) { - throw new Error('Sharing is not available on this device'); + throw new Error(t('files.share_unavailable', 'Sharing is not available on this device')); } await Sharing.shareAsync(file.uri, { mimeType: 'text/calendar', UTI: 'com.apple.ical.ics' }); } diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts index a61e92bf..19cb47de 100644 --- a/src/lib/calendar-notifications.ts +++ b/src/lib/calendar-notifications.ts @@ -5,6 +5,7 @@ import { jmapClient } from '../api/jmap-client'; import { useAuthStore } from '../stores/auth-store'; import { loadEventsInRange, useCalendarStore } from '../stores/calendar-store'; import { useSettingsStore } from '../stores/settings-store'; +import { useLocaleStore } from '../stores/locale-store'; import { hasCalendarCapability } from './capabilities'; import { onStateChangeType } from './state-change-bus'; import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler'; @@ -37,7 +38,8 @@ let permissionRequested = false; // Only the Calendar tab asks for notification permission; the launch-time // sync uses what was granted and never prompts out of the blue. let mayAskPermission = false; -let channelReady = false; +// The language the Android channel was last named in; null until created. +let channelLocale: string | null = null; let rescheduleTimer: ReturnType | null = null; let rescheduling: Promise | null = null; let queued = false; @@ -64,11 +66,15 @@ async function ensurePermission(): Promise { return permissionGranted; } +// The channel's name shows in the system notification settings, so it is +// (re)named in the app language; setting an existing channel only renames it. async function ensureChannel(): Promise { - if (channelReady || Platform.OS !== 'android') return; + if (Platform.OS !== 'android') return; + const { locale, t } = useLocaleStore.getState(); + if (channelLocale === locale) return; try { await Notifications.setNotificationChannelAsync(CHANNEL_ID, { - name: 'Calendar reminders', + name: t('calendar.notifications.channel_name', 'Calendar reminders'), importance: Notifications.AndroidImportance.HIGH, sound: 'default', vibrationPattern: [0, 250, 250, 250], @@ -76,7 +82,7 @@ async function ensureChannel(): Promise { } catch { // Channel creation failing only degrades to the default channel. } - channelReady = true; + channelLocale = locale; } function alertKeyOf(request: Notifications.NotificationRequest): string | null { @@ -84,6 +90,13 @@ function alertKeyOf(request: Notifications.NotificationRequest): string | null { return data?.tag === DATA_TAG && typeof data.key === 'string' ? data.key : null; } +// A reminder worded in another language than the app's now. Ones scheduled +// before reminders recorded their language count as current. +function isStaleLanguage(request: Notifications.NotificationRequest, locale: string): boolean { + const data = request.content.data as { locale?: unknown } | undefined; + return typeof data?.locale === 'string' && data.locale !== locale; +} + /** Cancel every calendar reminder this app scheduled. */ export async function cancelAllCalendarNotifications(): Promise { try { @@ -98,10 +111,12 @@ export async function cancelAllCalendarNotifications(): Promise { } } -async function scheduleOne(alert: ScheduledAlert): Promise { +async function scheduleOne(alert: ScheduledAlert, locale: string): Promise { const data: Record = { tag: DATA_TAG, key: alert.key, + // The language the title and body are in, to reword it on a switch. + locale, eventId: alert.eventId, kind: alert.kind, // Enough to find the event again when the reminder is tapped. @@ -204,11 +219,14 @@ export async function rescheduleCalendarNotifications(): Promise { if (!events) return; await ensureTasksLoaded(); const { tasks, calendars } = useCalendarStore.getState(); + // Reminders fire while the app is closed, so they are worded now, in + // the app language. + const { locale, t } = useLocaleStore.getState(); const wanted = getUpcomingAlerts(events, tasks, calendars, { now, horizonMs: HORIZON_MS, limit: MAX_SCHEDULED, - }); + }, t); const wantedByKey = new Map(wanted.map((a) => [a.key, a])); const pending = await Notifications.getAllScheduledNotificationsAsync(); @@ -216,7 +234,7 @@ export async function rescheduleCalendarNotifications(): Promise { for (const request of pending) { const key = alertKeyOf(request); if (key === null) continue; - if (wantedByKey.has(key)) { + if (wantedByKey.has(key) && !isStaleLanguage(request, locale)) { present.add(key); } else { await Notifications.cancelScheduledNotificationAsync(request.identifier); @@ -225,7 +243,7 @@ export async function rescheduleCalendarNotifications(): Promise { for (const alert of wanted) { if (present.has(alert.key)) continue; try { - await scheduleOne(alert); + await scheduleOne(alert, locale); } catch { // A single bad trigger must not block the rest. } @@ -358,6 +376,10 @@ export function startCalendarNotificationSync(options?: { askPermission?: boolea scheduleSoon(); } }); + // Reword the pending reminders and rename the channel in the new language. + useLocaleStore.subscribe((state, prev) => { + if (state.locale !== prev.locale) scheduleSoon(); + }); onStateChangeType('CalendarEvent', scheduleSoon); onStateChangeType('Calendar', scheduleSoon); AppState.addEventListener('change', (state) => { diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index cfe0066d..bfac097a 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createPersistStorage } from './persist-storage'; +import { t } from './locale-store'; import type { Calendar, CalendarEvent, CalendarRights, Participant, StateChange } from '../api/types'; import { normalizeAllDayDuration } from '../lib/calendar-utils'; import { @@ -175,19 +176,22 @@ export interface ImportResult { export class ImportRefusedError extends Error { refused: RefusedImport[]; constructor(refused: RefusedImport[]) { - const first = refused[0]?.reason ?? 'unknown error'; - super( - refused.length === 1 - ? `The event could not be imported: ${first}` - : `${refused.length} events could not be imported: ${first}`, - ); + super(t( + 'calendar.import.refused_message', + '{count, plural, =1 {The event could not be imported: {reason}} one {# event could not be imported: {reason}} other {# events could not be imported: {reason}}}', + { count: refused.length, reason: refused[0]?.reason ?? unknownReason() }, + )); this.name = 'ImportRefusedError'; this.refused = refused; } } +function unknownReason(): string { + return t('identities.validation_errors.unknown_error', 'Unknown error'); +} + function errorReason(err: unknown): string { - return err instanceof Error && err.message ? err.message : 'unknown error'; + return err instanceof Error && err.message ? err.message : unknownReason(); } export interface LoadedRange {