fix: translate the calendar import sheet

This commit is contained in:
Linus Rath
2026-09-24 20:34:35 +02:00
parent 9282ee6d72
commit b7f711050e
2 changed files with 72 additions and 27 deletions
+9 -1
View File
@@ -29,7 +29,15 @@
"time": "Time" "time": "Time"
}, },
"import": { "import": {
"refused_count": "{count, plural, one {# event could not be imported:} other {# events could not be imported:}}" "file_too_large_max": "That file is too large (max {size} MB).",
"imported_count": "{count, plural, one {Imported # event.} other {Imported # events.}}",
"no_calendar": "Create a calendar before importing events.",
"no_calendar_title": "No calendar",
"picker_error": "Could not open the file picker",
"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:}}",
"selected_count": "{count} selected"
}, },
"invitation": { "invitation": {
"accept": "Yes", "accept": "Yes",
+63 -26
View File
@@ -15,6 +15,8 @@ import { getCalendarColor } from '../../lib/calendar-utils';
import { isWritableCalendar } from '../../lib/calendar-editability'; import { isWritableCalendar } from '../../lib/calendar-editability';
import { ImportRefusedError, type ImportResult } from '../../stores/calendar-store'; import { ImportRefusedError, type ImportResult } from '../../stores/calendar-store';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import type { Locale } from 'date-fns';
// expo-document-picker is loaded lazily on first use; its native module is not // expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow). // linked into every build (matches the FilesScreen / ContactImportSheet flow).
@@ -42,11 +44,13 @@ interface Props {
onImported?: (count: number) => void; onImported?: (count: number) => void;
} }
function eventDateLabel(event: Partial<CalendarEvent>): string | null { function eventDateLabel(event: Partial<CalendarEvent>, locale: Locale): string | null {
if (!event.start) return null; if (!event.start) return null;
const d = parseISO(event.start); const d = parseISO(event.start);
if (isNaN(d.getTime())) return null; if (isNaN(d.getTime())) return null;
return event.showWithoutTime ? format(d, 'EEE, MMM d, yyyy') : format(d, 'EEE, MMM d · HH:mm'); return event.showWithoutTime
? format(d, 'EEE, MMM d, yyyy', { locale })
: format(d, 'EEE, MMM d · HH:mm', { locale });
} }
export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) { export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) {
@@ -54,6 +58,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const { locale } = useCalendarLocale();
const writable = React.useMemo( const writable = React.useMemo(
() => calendars.filter((cal) => isWritableCalendar(cal)), () => calendars.filter((cal) => isWritableCalendar(cal)),
@@ -101,8 +106,11 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
const picker = loadDocumentPicker(); const picker = loadDocumentPicker();
if (!picker) { if (!picker) {
Alert.alert( Alert.alert(
'Import unavailable', t('calendar.import.picker_unavailable_title', 'Import unavailable'),
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.', t(
'calendar.import.picker_unavailable',
'The file picker is not installed in this build. Rebuild the app to enable importing.',
),
); );
return; return;
} }
@@ -111,13 +119,13 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
try { try {
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false }); res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Could not open the file picker'); setError(e instanceof Error ? e.message : t('calendar.import.picker_error', 'Could not open the file picker'));
return; return;
} }
if (res.canceled || res.assets.length === 0) return; if (res.canceled || res.assets.length === 0) return;
const asset = res.assets[0]; const asset = res.assets[0];
if (asset.size && asset.size > 5 * 1024 * 1024) { if (asset.size && asset.size > 5 * 1024 * 1024) {
setError('That file is too large (max 5 MB).'); setError(t('calendar.import.file_too_large_max', 'That file is too large (max {size} MB).', { size: 5 }));
return; return;
} }
@@ -128,13 +136,13 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
const events = await parseCalendarBlob(blobId); const events = await parseCalendarBlob(blobId);
const usable = events.filter((e) => !!e.start || e['@type'] === 'Task'); const usable = events.filter((e) => !!e.start || e['@type'] === 'Task');
if (usable.length === 0) { if (usable.length === 0) {
setError('No events were found in that file.'); setError(t('calendar.import.no_events', 'No events found in file'));
return; return;
} }
setParsed(usable); setParsed(usable);
setSelected(new Set(usable.map((_, i) => i))); setSelected(new Set(usable.map((_, i) => i)));
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Could not read or parse that file.'); setError(e instanceof Error ? e.message : t('calendar.import.invalid_format', 'Invalid calendar file format'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -153,7 +161,10 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
const doImport = async () => { const doImport = async () => {
if (!calendarId) { if (!calendarId) {
Alert.alert('No calendar', 'Create a calendar before importing events.'); Alert.alert(
t('calendar.import.no_calendar_title', 'No calendar'),
t('calendar.import.no_calendar', 'Create a calendar before importing events.'),
);
return; return;
} }
const toImport = parsed.filter((_, i) => selected.has(i)); const toImport = parsed.filter((_, i) => selected.has(i));
@@ -166,7 +177,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
} catch (e) { } catch (e) {
// Everything was refused: list what and why, like a partial import. // Everything was refused: list what and why, like a partial import.
if (e instanceof ImportRefusedError) setResult({ imported: 0, refused: e.refused }); if (e instanceof ImportRefusedError) setResult({ imported: 0, refused: e.refused });
else setError(e instanceof Error ? e.message : 'Import failed.'); else setError(e instanceof Error ? e.message : t('calendar.import.error', 'Failed to import calendar'));
} finally { } finally {
setImporting(false); setImporting(false);
} }
@@ -180,7 +191,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}> <Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}> <SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>Import calendar</Text> <Text style={styles.title}>{t('calendar.import.title', 'Import Calendar')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}> <Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} /> <X size={20} color={c.text} />
</Pressable> </Pressable>
@@ -201,7 +212,11 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
<View style={{ flex: 1, minWidth: 0 }}> <View style={{ flex: 1, minWidth: 0 }}>
{result.imported > 0 && ( {result.imported > 0 && (
<Text style={styles.successText}> <Text style={styles.successText}>
Imported {result.imported} event{result.imported === 1 ? '' : 's'}. {t(
'calendar.import.imported_count',
'{count, plural, one {Imported # event.} other {Imported # events.}}',
{ count: result.imported },
)}
</Text> </Text>
)} )}
<Text style={styles.refusedTitle}> <Text style={styles.refusedTitle}>
@@ -215,12 +230,14 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
</View> </View>
<ScrollView style={{ flex: 1 }}> <ScrollView style={{ flex: 1 }}>
{result.refused.map(({ event, reason }, idx) => { {result.refused.map(({ event, reason }, idx) => {
const date = eventDateLabel(event); const date = eventDateLabel(event, locale);
return ( return (
<View key={idx} style={styles.row}> <View key={idx} style={styles.row}>
<AlertTriangle size={16} color={c.error} /> <AlertTriangle size={16} color={c.error} />
<View style={{ flex: 1, minWidth: 0 }}> <View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.rowName} numberOfLines={1}>{event.title || 'Untitled event'}</Text> <Text style={styles.rowName} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>} {!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>}
<Text style={styles.refusedReason} numberOfLines={3}>{reason}</Text> <Text style={styles.refusedReason} numberOfLines={3}>{reason}</Text>
</View> </View>
@@ -229,7 +246,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
})} })}
</ScrollView> </ScrollView>
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}> <View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
<Button variant="outline" onPress={onClose}>Done</Button> <Button variant="outline" onPress={onClose}>{t('common.done', 'Done')}</Button>
</View> </View>
</> </>
) : result !== null ? ( ) : result !== null ? (
@@ -238,9 +255,13 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
<Check size={26} color={c.primaryForeground} /> <Check size={26} color={c.primaryForeground} />
</View> </View>
<Text style={styles.successText}> <Text style={styles.successText}>
Imported {result.imported} event{result.imported === 1 ? '' : 's'}. {t(
'calendar.import.imported_count',
'{count, plural, one {Imported # event.} other {Imported # events.}}',
{ count: result.imported },
)}
</Text> </Text>
<Button variant="outline" size="sm" onPress={onClose}>Done</Button> <Button variant="outline" size="sm" onPress={onClose}>{t('common.done', 'Done')}</Button>
</View> </View>
) : parsed.length === 0 ? ( ) : parsed.length === 0 ? (
<View style={styles.center}> <View style={styles.center}>
@@ -250,8 +271,14 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
) : ( ) : (
<Upload size={32} color={c.textMuted} /> <Upload size={32} color={c.textMuted} />
)} )}
<Text style={styles.dropTitle}>{loading ? 'Parsing…' : 'Choose an .ics file'}</Text> <Text style={styles.dropTitle}>
<Text style={styles.dropHint}>iCalendar — single or multiple events</Text> {loading
? t('calendar.import.parsing', 'Parsing calendar file...')
: t('calendar.import.select_file', 'Select .ics file')}
</Text>
<Text style={styles.dropHint}>
{t('calendar.import.supported_formats', 'Supports iCalendar (.ics) files')}
</Text>
</Pressable> </Pressable>
{!!error && ( {!!error && (
<View style={styles.errorBox}> <View style={styles.errorBox}>
@@ -279,18 +306,24 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
<View style={styles.toolbar}> <View style={styles.toolbar}>
<Text style={styles.toolbarCount}> <Text style={styles.toolbarCount}>
{parsed.length} found · {selected.size} selected {t('calendar.import.parsed_events', '{count} events found', { count: parsed.length })}
{' · '}
{t('calendar.import.selected_count', '{count} selected', { count: selected.size })}
</Text> </Text>
<View style={{ flexDirection: 'row', gap: spacing.md }}> <View style={{ flexDirection: 'row', gap: spacing.md }}>
<Pressable onPress={selectAll} hitSlop={6}><Text style={styles.link}>All</Text></Pressable> <Pressable onPress={selectAll} hitSlop={6}>
<Pressable onPress={selectNone} hitSlop={6}><Text style={styles.link}>None</Text></Pressable> <Text style={styles.link}>{t('calendar.import.select_all', 'Select all')}</Text>
</Pressable>
<Pressable onPress={selectNone} hitSlop={6}>
<Text style={styles.link}>{t('calendar.import.deselect_all', 'Deselect all')}</Text>
</Pressable>
</View> </View>
</View> </View>
<ScrollView style={{ flex: 1 }}> <ScrollView style={{ flex: 1 }}>
{parsed.map((event, idx) => { {parsed.map((event, idx) => {
const isSelected = selected.has(idx); const isSelected = selected.has(idx);
const date = eventDateLabel(event); const date = eventDateLabel(event, locale);
return ( return (
<Pressable <Pressable
key={idx} key={idx}
@@ -302,7 +335,9 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
</View> </View>
<Clock size={16} color={c.textMuted} /> <Clock size={16} color={c.textMuted} />
<View style={{ flex: 1, minWidth: 0 }}> <View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.rowName} numberOfLines={1}>{event.title || 'Untitled event'}</Text> <Text style={styles.rowName} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>} {!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>}
</View> </View>
</Pressable> </Pressable>
@@ -318,13 +353,15 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
)} )}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}> <View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button> <Button variant="outline" onPress={onClose} disabled={importing}>{t('common.cancel', 'Cancel')}</Button>
<Button <Button
onPress={() => { void doImport(); }} onPress={() => { void doImport(); }}
disabled={importing || selected.size === 0} disabled={importing || selected.size === 0}
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <CalendarPlus size={16} color={c.primaryForeground} />} icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <CalendarPlus size={16} color={c.primaryForeground} />}
> >
{importing ? 'Importing…' : `Import ${selected.size}`} {importing
? t('calendar.import.importing', 'Importing events...')
: t('calendar.import.import_button', 'Import selected')}
</Button> </Button>
</View> </View>
</> </>