diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 913e9bcb..2fc8dc11 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; -import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react"; +import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react"; import { cn, formatDateTime } from "@/lib/utils"; import type { Calendar } from "@/lib/jmap/types"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; @@ -96,6 +96,7 @@ export function CalendarSidebarPanel({ ); const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); + const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar); const timeFormat = useSettingsStore((s) => s.timeFormat); const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors); const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks); @@ -219,6 +220,16 @@ export function CalendarSidebarPanel({ } }; + const handleSetDefault = async (calendarId: string) => { + if (!client) return; + try { + await setDefaultCalendar(client, calendarId); + toast.success(tMgmt('default_updated')); + } catch { + toast.error(tMgmt('error_default')); + } + }; + if (calendars.length === 0 && !onSubscribe) return null; const renderCalendarItem = (cal: Calendar) => { @@ -306,12 +317,13 @@ export function CalendarSidebarPanel({ const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID; const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false; const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared; + const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault; const canChangeColor = !!onColorChange; const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)]; const canResetColor = !!onResetColor && hasColorOverride; const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false; const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared; - const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete); + const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete); const color = cal.color || "#3b82f6"; return ( @@ -330,6 +342,13 @@ export function CalendarSidebarPanel({ onClick={() => { closeContextMenu(); onShareCalendar(cal); }} /> )} + {canSetDefault && ( + { closeContextMenu(); handleSetDefault(cal.id); }} + /> + )} {canChangeColor && (
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 1f0e4f67..23c21d43 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import { createPortal } from "react-dom"; import { Button } from "@/components/ui/button"; import { @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import { parseDuration, getEventColor } from "./event-card"; import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; +import { buildRecurrenceSummary } from "./recurrence-editor"; import { isOrganizer, getUserParticipantId, @@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType): string | null { +function getRecurrenceLabel(event: CalendarEvent, t: ReturnType, locale: string): string | null { if (!event.recurrenceRules?.length) return null; - const freq = event.recurrenceRules[0].frequency; - const labels: Record = { - daily: t("recurrence.daily"), - weekly: t("recurrence.weekly"), - monthly: t("recurrence.monthly"), - yearly: t("recurrence.yearly"), - }; - return labels[freq] || null; + return buildRecurrenceSummary(event.recurrenceRules[0], t, locale); } export function EventDetailPopover({ @@ -130,6 +124,7 @@ export function EventDetailPopover({ isMobile, }: EventDetailPopoverProps) { const t = useTranslations("calendar"); + const locale = useLocale(); const popoverRef = useRef(null); const noteInputRef = useRef(null); const [position, setPosition] = useState<{ top: number; left: number } | null>(null); @@ -158,7 +153,7 @@ export function EventDetailPopover({ }, [event.virtualLocations]); const participants = useMemo(() => getParticipantList(event), [event]); - const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]); + const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]); const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]); const userIsOrganizer = useMemo(() => { diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index c0d7c87a..5fc192e3 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -1,12 +1,13 @@ "use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react"; import { format, parseISO, addHours, addDays, isSameDay } from "date-fns"; -import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types"; +import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; +import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; @@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string { return dur; } -type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly"; +type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom"; type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks"; @@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType): string | null { +function getRecurrenceLabel(event: CalendarEvent, t: ReturnType, locale: string): string | null { if (!event.recurrenceRules?.length) return null; - const freq = event.recurrenceRules[0].frequency; - const labels: Record = { - daily: t("recurrence.daily"), - weekly: t("recurrence.weekly"), - monthly: t("recurrence.monthly"), - yearly: t("recurrence.yearly"), - }; - return labels[freq] || null; + return buildRecurrenceSummary(event.recurrenceRules[0], t, locale); } export function EventModal({ @@ -186,6 +180,7 @@ export function EventModal({ isMobile = false, }: EventModalProps) { const t = useTranslations("calendar"); + const locale = useLocale(); const timeFormat = useSettingsStore((s) => s.timeFormat); const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const isEdit = !!event; @@ -270,8 +265,35 @@ export function EventModal({ }); const [recurrence, setRecurrence] = useState(() => { if (!event?.recurrenceRules?.length) return "none"; - return event.recurrenceRules[0].frequency as RecurrenceOption; + const rule = event.recurrenceRules[0]; + return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom"; }); + const [customRule, setCustomRule] = useState(() => { + if (!event?.recurrenceRules?.length) return null; + const rule = event.recurrenceRules[0]; + return isSimpleRecurrenceRule(rule) ? null : rule; + }); + const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false); + // Dropdown value to restore when the custom editor is cancelled without a saved rule. + const recurrenceBeforeCustomRef = useRef("none"); + + const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => { + setCustomRule(rule); + setRecurrence("custom"); + setShowRecurrenceEditor(false); + }, []); + + const handleRecurrenceEditorCancel = useCallback(() => { + setShowRecurrenceEditor(false); + if (!customRule) { + setRecurrence(recurrenceBeforeCustomRef.current); + } + }, [customRule]); + + const customRuleSummary = useMemo( + () => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null), + [customRule, t, locale] + ); const preservedAlertsRef = useRef>({}); const [alertRows, setAlertRows] = useState(() => { if (!event?.alerts) return []; @@ -444,7 +466,9 @@ export function EventModal({ data.virtualLocations = null; } - if (recurrence !== "none") { + if (recurrence === "custom" && customRule) { + data.recurrenceRules = [customRule]; + } else if (recurrence !== "none" && recurrence !== "custom") { data.recurrenceRules = [{ "@type": "RecurrenceRule", frequency: recurrence, @@ -511,7 +535,7 @@ export function EventModal({ } finally { setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]); const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { if (!event || !userParticipantId || !onRsvp) return; @@ -737,7 +761,7 @@ export function EventModal({ const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null; const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null; const viewParticipants = getParticipantList(event); - const recurrenceLabel = getRecurrenceLabel(event, t); + const recurrenceLabel = getRecurrenceLabel(event, t, locale); const alertLabel = getAlertLabel(event, t); const eventCalendar = calendars.find(c => event.calendarIds[c.id]); const color = getEventColor(event, eventCalendar); @@ -1131,17 +1155,51 @@ export function EventModal({
- +
+ + {recurrence === "custom" && !showRecurrenceEditor && ( + + )} +
+ {showRecurrenceEditor && ( + { + const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`); + return isNaN(d.getTime()) ? new Date() : d; + })()} + onSave={handleRecurrenceEditorSave} + onCancel={handleRecurrenceEditorCancel} + /> + )}
diff --git a/components/calendar/recurrence-editor.tsx b/components/calendar/recurrence-editor.tsx new file mode 100644 index 00000000..94a4fa6a --- /dev/null +++ b/components/calendar/recurrence-editor.tsx @@ -0,0 +1,471 @@ +"use client"; + +import { useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { X } from "lucide-react"; +import { addYears, format } from "date-fns"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { CalendarRecurrenceRule } from "@/lib/jmap/types"; + +type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly"; +type MonthlyMode = "day" | "nth"; +type EndsMode = "never" | "on" | "after"; + +const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"]; + +// 2024-01-01 is a Monday - used to render localized weekday names via Intl. +const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"]; +const DAY_TO_REF_DATE: Record = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 }; +const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"]; + +const FREQ_LABEL_KEYS: Record = { + daily: "recurrence.editor_freq_day", + weekly: "recurrence.editor_freq_week", + monthly: "recurrence.editor_freq_month", + yearly: "recurrence.editor_freq_year", +}; + +const UNIT_LABEL_KEYS: Record = { + daily: "recurrence.editor_unit_days", + weekly: "recurrence.editor_unit_weeks", + monthly: "recurrence.editor_unit_months", + yearly: "recurrence.editor_unit_years", +}; + +type CalendarT = ReturnType; + +function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string { + const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1); + return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref); +} + +function monthName(month: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1)); +} + +function nthLabel(nth: number, t: CalendarT): string { + if (nth === -1) return t("recurrence.nth_last"); + if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`); + return String(nth); +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Extract an "nth weekday" pattern from a rule, accepting both the + * byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding. + */ +function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null { + if (rule.byDay?.length === 1) { + const nd = rule.byDay[0]; + if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod }; + if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] }; + } + return null; +} + +/** + * True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly + * dropdown presets produce, i.e. it needs no custom editor to represent. + */ +export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean { + return ( + (EDITOR_FREQUENCIES as string[]).includes(rule.frequency) && + (!rule.interval || rule.interval === 1) && + !rule.byDay?.length && + !rule.byMonthDay?.length && + !rule.byMonth?.length && + !rule.byYearDay?.length && + !rule.byWeekNo?.length && + !rule.bySetPosition?.length && + !rule.count && + !rule.until + ); +} + +/** + * Human-readable summary of a recurrence rule, e.g. + * "Every 2 months on the third Thursday · 12 occurrences". + * Returns null for frequencies the UI cannot describe (hourly etc.). + */ +export function buildRecurrenceSummary( + rule: CalendarRecurrenceRule, + t: CalendarT, + locale: string, +): string | null { + const interval = rule.interval || 1; + let base: string; + switch (rule.frequency) { + case "daily": + base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily"); + break; + case "weekly": + base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly"); + break; + case "monthly": + base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly"); + break; + case "yearly": + base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly"); + break; + default: + return null; + } + + const parts = [base]; + + if (rule.frequency === "weekly" && rule.byDay?.length) { + const days = rule.byDay + .filter((d) => WEEKDAYS.includes(d.day)) + .sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day)) + .map((d) => weekdayName(d.day, locale, "short")) + .join(", "); + if (days) parts.push(t("recurrence.on_days", { days })); + } + + if (rule.frequency === "monthly" || rule.frequency === "yearly") { + if (rule.frequency === "yearly" && rule.byMonth?.length) { + const m = parseInt(rule.byMonth[0], 10); + if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) })); + } + const nthDay = getNthDay(rule); + if (nthDay) { + parts.push(t("recurrence.on_the_nth", { + nth: nthLabel(nthDay.nth, t), + day: weekdayName(nthDay.day, locale), + })); + } else if (rule.byMonthDay?.length) { + parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] })); + } + } + + let summary = parts.join(" "); + if (rule.count) { + summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`; + } else if (rule.until) { + const d = new Date(rule.until); + if (!isNaN(d.getTime())) { + summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`; + } + } + return summary; +} + +interface RecurrenceEditorProps { + rule: CalendarRecurrenceRule | null; + eventStart: Date; + onSave: (rule: CalendarRecurrenceRule) => void; + onCancel: () => void; +} + +export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) { + const t = useTranslations("calendar"); + const locale = useLocale(); + + const startDay = INDEX_TO_DAY[eventStart.getDay()]; + const initialNthDay = rule ? getNthDay(rule) : null; + + const [frequency, setFrequency] = useState(() => + rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency) + ? (rule.frequency as EditorFrequency) + : "weekly" + ); + const [interval, setIntervalValue] = useState(rule?.interval || 1); + const [weekDays, setWeekDays] = useState(() => { + if (rule?.frequency === "weekly" && rule.byDay?.length) { + const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d)); + if (days.length) return days; + } + return [startDay]; + }); + const [monthlyMode, setMonthlyMode] = useState(initialNthDay ? "nth" : "day"); + const [monthDay, setMonthDay] = useState(() => { + const md = rule?.byMonthDay?.[0]; + return md && md >= 1 && md <= 31 ? md : eventStart.getDate(); + }); + const [nth, setNth] = useState(() => { + if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) { + return initialNthDay.nth; + } + return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1); + }); + const [nthDay, setNthDay] = useState(() => + initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay + ); + const [month, setMonth] = useState(() => { + const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN; + return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1; + }); + const [endsMode, setEndsMode] = useState(rule?.count ? "after" : rule?.until ? "on" : "never"); + const [untilDate, setUntilDate] = useState(() => { + if (rule?.until) { + const d = new Date(rule.until); + if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd"); + } + return format(addYears(eventStart, 1), "yyyy-MM-dd"); + }); + const [count, setCount] = useState(rule?.count ?? 12); + + const toggleWeekDay = (day: string) => { + setWeekDays((prev) => + prev.includes(day) + ? prev.length > 1 ? prev.filter((d) => d !== day) : prev + : [...prev, day] + ); + }; + + const handleSave = () => { + const built: CalendarRecurrenceRule = { + "@type": "RecurrenceRule", + frequency, + interval: Math.max(1, interval), + rscale: "gregorian", + skip: "omit", + firstDayOfWeek: "mo", + byDay: null, + byMonthDay: null, + byMonth: null, + byYearDay: null, + byWeekNo: null, + byHour: null, + byMinute: null, + bySecond: null, + bySetPosition: null, + count: endsMode === "after" ? Math.max(1, count) : null, + until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null, + }; + + if (frequency === "weekly") { + const days = weekDays.length ? weekDays : [startDay]; + built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day })); + } else if (frequency === "monthly" || frequency === "yearly") { + if (monthlyMode === "nth") { + built.byDay = [{ day: nthDay, nthOfPeriod: nth }]; + } else { + built.byMonthDay = [Math.min(31, Math.max(1, monthDay))]; + } + if (frequency === "yearly") { + built.byMonth = [String(month)]; + } + } + + onSave(built); + }; + + const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"; + + return ( +
+
+
+ {EDITOR_FREQUENCIES.map((f) => ( + + ))} +
+ +
+ +
+

{t("recurrence.editor_repeats_on")}

+
+ {t("recurrence.editor_every")} + { + const n = parseInt(e.target.value, 10); + setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1); + }} + className="w-20" + aria-label={t("recurrence.editor_every")} + /> + {t(UNIT_LABEL_KEYS[frequency])} +
+ + {frequency === "weekly" && ( +
+ {WEEKDAYS.map((day) => ( + + ))} +
+ )} + + {frequency === "yearly" && ( +
+ {t("recurrence.editor_in")} + +
+ )} + + {(frequency === "monthly" || frequency === "yearly") && ( +
+ + +
+ )} +
+ +
+

{t("recurrence.editor_ends")}

+ + + +
+ +
+ + +
+
+ ); +} diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 38d2a2a6..0d4a9456 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -701,6 +701,12 @@ export class DemoJMAPClient implements IJMAPClient { if (cal) Object.assign(cal, updates); } + async setDefaultCalendar(calendarId: string): Promise { + for (const cal of this.data.calendars) { + cal.isDefault = cal.id === calendarId; + } + } + async deleteCalendar(calendarId: string): Promise { this.data.calendars = this.data.calendars.filter(c => c.id !== calendarId); this.data.calendarEvents = this.data.calendarEvents.filter(e => !e.calendarIds[calendarId]); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index e635765d..06926548 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -263,6 +263,7 @@ export interface IJMAPClient { getAllCalendars(): Promise; createCalendar(calendar: Partial, targetAccountId?: string): Promise; updateCalendar(calendarId: string, updates: Partial, targetAccountId?: string): Promise; + setDefaultCalendar(calendarId: string, targetAccountId?: string): Promise; deleteCalendar(calendarId: string, targetAccountId?: string): Promise; getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise; getCalendarEvent(id: string, targetAccountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 538c6af5..219fa4ce 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -125,6 +125,23 @@ const CALENDAR_PROPERTIES = [ "myRights", ] as const; +// Properties Stalwart's Calendar/set accepts in create/update. Anything else +// (id, isDefault, myRights, client-side bookkeeping fields) makes the whole +// update fail with invalidProperties ("Field could not be set"). +const CALENDAR_SETTABLE_PROPERTIES = new Set([ + "name", + "description", + "color", + "timeZone", + "sortOrder", + "isSubscribed", + "isVisible", + "includeInAvailability", + "defaultAlertsWithTime", + "defaultAlertsWithoutTime", + "shareWith", +]); + // Stalwart's default property list for AddressBook/get omits shareWith, so // existing shares would be invisible after a fresh login. const ADDRESS_BOOK_PROPERTIES = [ @@ -235,6 +252,22 @@ const CALENDAR_TASK_PROPERTIES = [ 'percentComplete', // Task-only per RFC 8984 §5.2.4 - used in detection heuristic ] as const; +/** + * IANA time zone of the browser, sent as the `timeZone` argument on + * CalendarEvent/query and CalendarEvent/get. Stalwart interprets the + * LocalDateTime `after`/`before` filter values and computes utcStart/utcEnd + * for floating events in this zone, defaulting to UTC when absent - which + * shifts range boundaries and floating-event times for any user not in UTC. + * Stalwart ignores unparseable values, so sending it is always safe. + */ +function getUserTimeZone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} + /** * Stalwart's calcard crate uses singular property names ("recurrenceRule") * instead of the RFC 8984 plural forms ("recurrenceRules"). @@ -4196,11 +4229,24 @@ export class JMAPClient implements IJMAPClient { async updateCalendar(calendarId: string, updates: Partial, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); + // Stalwart rejects the whole update with invalidProperties if any key is + // not settable (e.g. id, isDefault, myRights, or client-only fields), so + // only forward the properties its Calendar/set actually accepts. Keys + // containing '/' are JSON-pointer patches; keep those whose root segment + // is settable (shareWith/..., defaultAlertsWithTime/...). + const cleanUpdates: Record = {}; + for (const [key, value] of Object.entries(updates as Record)) { + const root = key.split('/', 1)[0]; + if (CALENDAR_SETTABLE_PROPERTIES.has(root)) { + cleanUpdates[key] = value; + } + } + const response = await this.request([ ["Calendar/set", { accountId, update: { - [calendarId]: updates + [calendarId]: cleanUpdates } }, "0"] ], this.calendarUsing()); @@ -4218,6 +4264,31 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to update calendar"); } + /** + * Mark a calendar as the account default. `isDefault` is read-only in + * Stalwart's Calendar/set - the default is changed via the + * `onSuccessSetIsDefault` request argument instead. + */ + async setDefaultCalendar(calendarId: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); + + const response = await this.request([ + ["Calendar/set", { + accountId, + onSuccessSetIsDefault: calendarId + }, "0"] + ], this.calendarUsing()); + + const methodName = response.methodResponses?.[0]?.[0]; + if (methodName === "error") { + const error = response.methodResponses?.[0]?.[1]; + throw new Error(error?.description || error?.type || "Failed to set default calendar"); + } + if (methodName !== "Calendar/set") { + throw new Error("Failed to set default calendar"); + } + } + async deleteCalendar(calendarId: string, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); @@ -4245,8 +4316,12 @@ export class JMAPClient implements IJMAPClient { async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); const GET_BATCH_SIZE = this.getMaxObjectsInGet(); + const timeZone = getUserTimeZone(); const queryArgs: Record = { accountId, limit: 1000 }; + if (timeZone) { + queryArgs.timeZone = timeZone; + } if (calendarIds && calendarIds.length > 0) { queryArgs.filter = buildInCalendarFilter(calendarIds); } @@ -4274,6 +4349,7 @@ export class JMAPClient implements IJMAPClient { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: batchIds, + ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4337,12 +4413,18 @@ export class JMAPClient implements IJMAPClient { ): Promise { try { const accountId = targetAccountId || this.getCalendarsAccountId(); + const timeZone = getUserTimeZone(); const queryArgs: Record = { accountId, filter, limit: limit || 1000, }; + // Interpret the LocalDateTime after/before filter values in the user's + // time zone (Stalwart defaults to UTC, shifting range boundaries). + if (timeZone) { + queryArgs.timeZone = timeZone; + } // NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic // IDs that cannot be used for CalendarEvent/set (update/destroy). // Recurrence expansion is done client-side instead. @@ -4374,6 +4456,7 @@ export class JMAPClient implements IJMAPClient { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: batchIds, + ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4410,11 +4493,13 @@ export class JMAPClient implements IJMAPClient { async getCalendarEvent(id: string, targetAccountId?: string): Promise { try { const accountId = targetAccountId || this.getCalendarsAccountId(); + const timeZone = getUserTimeZone(); const response = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: [id], + ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4568,11 +4653,13 @@ export class JMAPClient implements IJMAPClient { } // Fetch all created events in a single CalendarEvent/get + const refetchTimeZone = getUserTimeZone(); const getResponse = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: createdIds, + ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4767,9 +4854,13 @@ export class JMAPClient implements IJMAPClient { // Page through the query to collect all object ids. const QUERY_PAGE = 1000; const MAX_IDS = 50000; // safety bound + const timeZone = getUserTimeZone(); const ids: string[] = []; for (let position = 0; position < MAX_IDS;) { const queryArgs: Record = { accountId, limit: QUERY_PAGE, position }; + if (timeZone) { + queryArgs.timeZone = timeZone; + } if (calendarIds && calendarIds.length > 0) { queryArgs.filter = buildInCalendarFilter(calendarIds); } @@ -4806,6 +4897,7 @@ export class JMAPClient implements IJMAPClient { accountId, properties: [...CALENDAR_TASK_PROPERTIES], ids: batchIds, + ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4884,11 +4976,13 @@ export class JMAPClient implements IJMAPClient { // Fetch back with task-specific properties debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] }); + const refetchTimeZone = getUserTimeZone(); const getResponse = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_TASK_PROPERTIES], ids: [createdId], + ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), }, "0"] ], this.calendarUsing()); diff --git a/locales/cs/common.json b/locales/cs/common.json index 9d9c04e6..4c8ffba1 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopírovat", "copied": "Zkopírováno", "password_instructions": "Použijte výše uvedené uživatelské jméno JMAP společně s heslem aplikace pro přihlášení v poštovním klientovi. Pokud jste tak ještě neučinili, vytvořte si heslo aplikace v sekci výše." + }, + "link_device": { + "title": "Propojit mobilní aplikaci", + "description": "Přihlaste se do mobilní aplikace Bulwark Mail bez psaní. Vygenerujte zde QR kód a naskenujte jej na přihlašovací obrazovce aplikace.", + "generate": "Zobrazit QR kód", + "regenerate": "Zobrazit nový kód", + "instructions": "Otevřete aplikaci Bulwark Mail, na přihlašovací obrazovce klepněte na \"Naskenovat QR kód\" a namiřte fotoaparát sem.", + "expires_in": "Tento kód vyprší za {seconds} sekund. Lze jej použít pouze jednou.", + "expired": "Platnost tohoto kódu vypršela.", + "generating": "Generování…", + "error": "Párovací kód se nepodařilo vytvořit. Zkuste to prosím znovu." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Každých {count} týdnů", "every_n_months": "Každých {count} měsíců", "until": "Do", - "occurrences": "{count} opakování" + "occurrences": "{count} opakování", + "custom": "Vlastní…", + "edit_custom": "Upravit vlastní opakování", + "every_n_years": "Každých {count} let", + "on_days": "v {days}", + "on_day_n": "v den {day}", + "on_the_nth": "{nth} {day}", + "in_month": "v měsíci {month}", + "nth_1": "první", + "nth_2": "druhý", + "nth_3": "třetí", + "nth_4": "čtvrtý", + "nth_last": "poslední", + "editor_freq_day": "Den", + "editor_freq_week": "Týden", + "editor_freq_month": "Měsíc", + "editor_freq_year": "Rok", + "editor_repeats_on": "Opakuje se", + "editor_every": "Každých", + "editor_unit_days": "dní", + "editor_unit_weeks": "týdnů", + "editor_unit_months": "měsíců", + "editor_unit_years": "let", + "editor_on_day": "v den", + "editor_on_the": "v", + "editor_in": "v měsíci", + "editor_ends": "Končí", + "editor_never": "Nikdy", + "editor_ends_on": "Dne", + "editor_ends_after": "Po", + "editor_occurrences": "opakováních" }, "recurrence_scope": { "edit_title": "Upravit opakující se událost", @@ -2652,6 +2693,9 @@ "default": "Výchozí", "confirm_delete": "Odstranit \"{name}\"? Všechny události v tomto kalendáři budou odstraněny.", "confirm_clear": "Vymazat všechny události z \"{name}\"? Tuto akci nelze vrátit zpět.", + "set_default": "Nastavit jako výchozí", + "default_updated": "Výchozí kalendář aktualizován", + "error_default": "Nepodařilo se nastavit výchozí kalendář", "clear_events": "Vymazat události", "events_cleared": "Vymazáno {count} událostí", "error_clear": "Vymazání událostí kalendáře selhalo", diff --git a/locales/da/common.json b/locales/da/common.json index 9d1aed8f..d0a1e6f8 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1383,6 +1383,17 @@ "copy": "Kopier", "copied": "Kopieret", "password_instructions": "Brug dit JMAP-brugernavn ovenfor sammen med en app-adgangskode for at logge ind i din e-mailklient. Opret en app-adgangskode i sektionen ovenfor, hvis du ikke allerede har gjort det." + }, + "link_device": { + "title": "Tilknyt mobilapp", + "description": "Log ind i Bulwark Mail-mobilappen uden at skrive noget. Generér en QR-kode her, og scan den fra appens loginskærm.", + "generate": "Vis QR-kode", + "regenerate": "Vis en ny kode", + "instructions": "Åbn Bulwark Mail-appen, tryk på \"Scan QR-kode\" på loginskærmen, og ret dit kamera herhen.", + "expires_in": "Denne kode udløber om {seconds} sekunder. Den kan kun bruges én gang.", + "expired": "Denne kode er udløbet.", + "generating": "Genererer…", + "error": "Kunne ikke oprette en parringskode. Prøv igen." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Hver {count} uge", "every_n_months": "Hver {count} måned", "until": "Indtil", - "occurrences": "{count} forekomster" + "occurrences": "{count} forekomster", + "custom": "Tilpasset…", + "edit_custom": "Rediger tilpasset gentagelse", + "every_n_years": "Hvert {count}. år", + "on_days": "på {days}", + "on_day_n": "på dag {day}", + "on_the_nth": "den {nth} {day}", + "in_month": "i {month}", + "nth_1": "første", + "nth_2": "anden", + "nth_3": "tredje", + "nth_4": "fjerde", + "nth_last": "sidste", + "editor_freq_day": "Dag", + "editor_freq_week": "Uge", + "editor_freq_month": "Måned", + "editor_freq_year": "År", + "editor_repeats_on": "Gentages", + "editor_every": "Hver", + "editor_unit_days": "dag(e)", + "editor_unit_weeks": "uge(r)", + "editor_unit_months": "måned(er)", + "editor_unit_years": "år", + "editor_on_day": "på dag", + "editor_on_the": "den", + "editor_in": "i", + "editor_ends": "Slutter", + "editor_never": "Aldrig", + "editor_ends_on": "Den", + "editor_ends_after": "Efter", + "editor_occurrences": "gentagelser" }, "recurrence_scope": { "edit_title": "Redigér tilbagevendende begivenhed", @@ -2666,6 +2707,9 @@ "default": "Standard", "confirm_delete": "Slet \"{name}\"? Alle begivenheder i denne kalender fjernes.", "confirm_clear": "Ryd alle begivenheder fra \"{name}\"? Dette kan ikke fortrydes.", + "set_default": "Angiv som standard", + "default_updated": "Standardkalender opdateret", + "error_default": "Kunne ikke angive standardkalender", "clear_events": "Ryd begivenheder", "events_cleared": "{count} begivenheder ryddet", "error_clear": "Kunne ikke rydde kalenderbegivenheder", diff --git a/locales/de/common.json b/locales/de/common.json index 360ba6ee..38bbf347 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopieren", "copied": "Kopiert", "password_instructions": "Verwenden Sie Ihren oben angezeigten JMAP-Benutzernamen zusammen mit einem App-Passwort, um sich bei Ihrem E-Mail-Client anzumelden. Erstellen Sie im obigen Abschnitt ein App-Passwort, falls Sie noch keines haben." + }, + "link_device": { + "title": "Mobile App verknüpfen", + "description": "Melden Sie sich in der Bulwark Mail App an, ohne etwas einzutippen. Erzeugen Sie hier einen QR-Code und scannen Sie ihn auf dem Anmeldebildschirm der App.", + "generate": "QR-Code anzeigen", + "regenerate": "Neuen Code anzeigen", + "instructions": "Öffnen Sie die Bulwark Mail App, tippen Sie auf dem Anmeldebildschirm auf \"QR-Code scannen\" und richten Sie die Kamera hierauf.", + "expires_in": "Dieser Code läuft in {seconds} Sekunden ab. Er kann nur einmal verwendet werden.", + "expired": "Dieser Code ist abgelaufen.", + "generating": "Wird erstellt…", + "error": "Kopplungscode konnte nicht erstellt werden. Bitte versuchen Sie es erneut." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Alle {count} Wochen", "every_n_months": "Alle {count} Monate", "until": "Bis", - "occurrences": "{count} Wiederholungen" + "occurrences": "{count} Wiederholungen", + "custom": "Benutzerdefiniert…", + "edit_custom": "Benutzerdefinierte Wiederholung bearbeiten", + "every_n_years": "Alle {count} Jahre", + "on_days": "am {days}", + "on_day_n": "am Tag {day}", + "on_the_nth": "am {nth} {day}", + "in_month": "im {month}", + "nth_1": "ersten", + "nth_2": "zweiten", + "nth_3": "dritten", + "nth_4": "vierten", + "nth_last": "letzten", + "editor_freq_day": "Tag", + "editor_freq_week": "Woche", + "editor_freq_month": "Monat", + "editor_freq_year": "Jahr", + "editor_repeats_on": "Wiederholt sich", + "editor_every": "Alle", + "editor_unit_days": "Tag(e)", + "editor_unit_weeks": "Woche(n)", + "editor_unit_months": "Monat(e)", + "editor_unit_years": "Jahr(e)", + "editor_on_day": "am Tag", + "editor_on_the": "am", + "editor_in": "im", + "editor_ends": "Endet", + "editor_never": "Nie", + "editor_ends_on": "Am", + "editor_ends_after": "Nach", + "editor_occurrences": "Terminen" }, "recurrence_scope": { "edit_title": "Wiederkehrendes Ereignis bearbeiten", @@ -2662,6 +2703,9 @@ "copy_url": "CalDAV-URL kopieren", "url_copied": "CalDAV-URL in die Zwischenablage kopiert", "confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.", + "set_default": "Als Standard festlegen", + "default_updated": "Standardkalender aktualisiert", + "error_default": "Standardkalender konnte nicht festgelegt werden", "clear_events": "Ereignisse löschen", "events_cleared": "{count} Ereignisse gelöscht", "error_clear": "Kalenderereignisse konnten nicht gelöscht werden", diff --git a/locales/en/common.json b/locales/en/common.json index 947b4453..fba1e9ff 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2501,7 +2501,37 @@ "every_n_weeks": "Every {count} weeks", "every_n_months": "Every {count} months", "until": "Until", - "occurrences": "{count} occurrences" + "occurrences": "{count} occurrences", + "custom": "Custom…", + "edit_custom": "Edit custom recurrence", + "every_n_years": "Every {count} years", + "on_days": "on {days}", + "on_day_n": "on day {day}", + "on_the_nth": "on the {nth} {day}", + "in_month": "in {month}", + "nth_1": "first", + "nth_2": "second", + "nth_3": "third", + "nth_4": "fourth", + "nth_last": "last", + "editor_freq_day": "Day", + "editor_freq_week": "Week", + "editor_freq_month": "Month", + "editor_freq_year": "Year", + "editor_repeats_on": "Repeats on", + "editor_every": "Every", + "editor_unit_days": "day(s)", + "editor_unit_weeks": "week(s)", + "editor_unit_months": "month(s)", + "editor_unit_years": "year(s)", + "editor_on_day": "on day", + "editor_on_the": "on the", + "editor_in": "in", + "editor_ends": "Ends", + "editor_never": "Never", + "editor_ends_on": "On", + "editor_ends_after": "After", + "editor_occurrences": "occurrences" }, "recurrence_scope": { "edit_title": "Edit recurring event", @@ -2678,6 +2708,9 @@ "default": "Default", "confirm_delete": "Delete \"{name}\"? All events in this calendar will be removed.", "confirm_clear": "Clear all events from \"{name}\"? This cannot be undone.", + "set_default": "Set as default", + "default_updated": "Default calendar updated", + "error_default": "Failed to set default calendar", "clear_events": "Clear events", "events_cleared": "{count} events cleared", "error_clear": "Failed to clear calendar events", diff --git a/locales/es/common.json b/locales/es/common.json index e14d9da9..b117dc99 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1380,6 +1380,17 @@ "copy": "Copiar", "copied": "Copiado", "password_instructions": "Use su nombre de usuario JMAP indicado arriba junto con una contraseña de aplicación para iniciar sesión en su cliente de correo. Cree una contraseña de aplicación en la sección anterior si aún no lo ha hecho." + }, + "link_device": { + "title": "Vincular aplicación móvil", + "description": "Inicie sesión en la aplicación móvil de Bulwark Mail sin escribir nada. Genere un código QR aquí y escanéelo desde la pantalla de inicio de sesión de la aplicación.", + "generate": "Mostrar código QR", + "regenerate": "Mostrar un código nuevo", + "instructions": "Abra la aplicación Bulwark Mail, toque \"Escanear código QR\" en la pantalla de inicio de sesión y apunte su cámara aquí.", + "expires_in": "Este código caduca en {seconds} segundos. Solo se puede usar una vez.", + "expired": "Este código ha caducado.", + "generating": "Generando…", + "error": "No se pudo crear un código de vinculación. Inténtelo de nuevo." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Cada {count} semanas", "every_n_months": "Cada {count} meses", "until": "Hasta", - "occurrences": "{count} repeticiones" + "occurrences": "{count} repeticiones", + "custom": "Personalizado…", + "edit_custom": "Editar repetición personalizada", + "every_n_years": "Cada {count} años", + "on_days": "los {days}", + "on_day_n": "el día {day}", + "on_the_nth": "el {nth} {day}", + "in_month": "en {month}", + "nth_1": "primer", + "nth_2": "segundo", + "nth_3": "tercer", + "nth_4": "cuarto", + "nth_last": "último", + "editor_freq_day": "Día", + "editor_freq_week": "Semana", + "editor_freq_month": "Mes", + "editor_freq_year": "Año", + "editor_repeats_on": "Se repite", + "editor_every": "Cada", + "editor_unit_days": "día(s)", + "editor_unit_weeks": "semana(s)", + "editor_unit_months": "mes(es)", + "editor_unit_years": "año(s)", + "editor_on_day": "el día", + "editor_on_the": "el", + "editor_in": "en", + "editor_ends": "Termina", + "editor_never": "Nunca", + "editor_ends_on": "El", + "editor_ends_after": "Después de", + "editor_occurrences": "repeticiones" }, "recurrence_scope": { "edit_title": "Editar evento recurrente", @@ -2662,6 +2703,9 @@ "copy_url": "Copiar URL de CalDAV", "url_copied": "URL de CalDAV copiada al portapapeles", "confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.", + "set_default": "Establecer como predeterminado", + "default_updated": "Calendario predeterminado actualizado", + "error_default": "Error al establecer el calendario predeterminado", "clear_events": "Borrar eventos", "events_cleared": "{count} eventos borrados", "error_clear": "No se pudieron borrar los eventos del calendario", diff --git a/locales/fr/common.json b/locales/fr/common.json index e4dd9846..55086fa8 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1380,6 +1380,17 @@ "copy": "Copier", "copied": "Copié", "password_instructions": "Utilisez votre nom d'utilisateur JMAP ci-dessus avec un mot de passe d'application pour vous connecter à votre client de messagerie. Créez un mot de passe d'application dans la section ci-dessus si vous ne l'avez pas encore fait." + }, + "link_device": { + "title": "Associer l'application mobile", + "description": "Connectez-vous à l'application mobile Bulwark Mail sans rien saisir. Générez un QR code ici et scannez-le depuis l'écran de connexion de l'application.", + "generate": "Afficher le QR code", + "regenerate": "Afficher un nouveau code", + "instructions": "Ouvrez l'application Bulwark Mail, appuyez sur \"Scanner le QR code\" sur l'écran de connexion et pointez votre caméra ici.", + "expires_in": "Ce code expire dans {seconds} secondes. Il ne peut être utilisé qu'une seule fois.", + "expired": "Ce code a expiré.", + "generating": "Génération…", + "error": "Impossible de créer un code d'association. Veuillez réessayer." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Toutes les {count} semaines", "every_n_months": "Tous les {count} mois", "until": "Jusqu'au", - "occurrences": "{count} occurrences" + "occurrences": "{count} occurrences", + "custom": "Personnalisé…", + "edit_custom": "Modifier la récurrence personnalisée", + "every_n_years": "Tous les {count} ans", + "on_days": "le {days}", + "on_day_n": "le jour {day}", + "on_the_nth": "le {nth} {day}", + "in_month": "en {month}", + "nth_1": "premier", + "nth_2": "deuxième", + "nth_3": "troisième", + "nth_4": "quatrième", + "nth_last": "dernier", + "editor_freq_day": "Jour", + "editor_freq_week": "Semaine", + "editor_freq_month": "Mois", + "editor_freq_year": "Année", + "editor_repeats_on": "Se répète", + "editor_every": "Tous les", + "editor_unit_days": "jour(s)", + "editor_unit_weeks": "semaine(s)", + "editor_unit_months": "mois", + "editor_unit_years": "an(s)", + "editor_on_day": "le jour", + "editor_on_the": "le", + "editor_in": "en", + "editor_ends": "Se termine", + "editor_never": "Jamais", + "editor_ends_on": "Le", + "editor_ends_after": "Après", + "editor_occurrences": "occurrences" }, "recurrence_scope": { "edit_title": "Modifier l'événement récurrent", @@ -2676,6 +2717,9 @@ "copy_url": "Copier l'URL CalDAV", "url_copied": "URL CalDAV copiée dans le presse-papiers", "confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.", + "set_default": "Définir par défaut", + "default_updated": "Calendrier par défaut mis à jour", + "error_default": "Échec de la définition du calendrier par défaut", "clear_events": "Supprimer les événements", "events_cleared": "{count} événements supprimés", "error_clear": "Impossible de supprimer les événements du calendrier", diff --git a/locales/hu/common.json b/locales/hu/common.json index 8f7a1e3d..3abc697a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1383,6 +1383,17 @@ "copy": "Másolás", "copied": "Másolva", "password_instructions": "Használd a fenti JMAP felhasználóneved egy alkalmazás jelszóval együtt az e-mail kliensbe való bejelentkezéshez. Hozz létre egy alkalmazás jelszót a fenti részben, ha még nem tetted." + }, + "link_device": { + "title": "Mobilalkalmazás összekapcsolása", + "description": "Jelentkezz be a Bulwark Mail mobilalkalmazásba gépelés nélkül. Generálj itt egy QR-kódot, és olvasd be az alkalmazás bejelentkezési képernyőjén.", + "generate": "QR-kód megjelenítése", + "regenerate": "Új kód megjelenítése", + "instructions": "Nyisd meg a Bulwark Mail alkalmazást, koppints a \"QR-kód beolvasása\" lehetőségre a bejelentkezési képernyőn, és irányítsd ide a kamerát.", + "expires_in": "Ez a kód {seconds} másodperc múlva lejár. Csak egyszer használható.", + "expired": "Ez a kód lejárt.", + "generating": "Generálás…", + "error": "Nem sikerült párosítási kódot létrehozni. Próbáld újra." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Minden {count} hét", "every_n_months": "Minden {count} hónap", "until": "Amíg", - "occurrences": "{count} alkalom" + "occurrences": "{count} alkalom", + "custom": "Egyéni…", + "edit_custom": "Egyéni ismétlődés szerkesztése", + "every_n_years": "Minden {count}. évben", + "on_days": "ezeken a napokon: {days}", + "on_day_n": "a hónap {day}. napján", + "on_the_nth": "a(z) {nth} {day}", + "in_month": "{month} hónapban", + "nth_1": "első", + "nth_2": "második", + "nth_3": "harmadik", + "nth_4": "negyedik", + "nth_last": "utolsó", + "editor_freq_day": "Nap", + "editor_freq_week": "Hét", + "editor_freq_month": "Hónap", + "editor_freq_year": "Év", + "editor_repeats_on": "Ismétlődés", + "editor_every": "Minden", + "editor_unit_days": "nap", + "editor_unit_weeks": "hét", + "editor_unit_months": "hónap", + "editor_unit_years": "év", + "editor_on_day": "a hónap napján:", + "editor_on_the": "a(z)", + "editor_in": "hónap:", + "editor_ends": "Vége", + "editor_never": "Soha", + "editor_ends_on": "Ekkor", + "editor_ends_after": "Ennyi után:", + "editor_occurrences": "alkalom" }, "recurrence_scope": { "edit_title": "Ismétlődő esemény szerkesztése", @@ -2667,6 +2708,9 @@ "default": "Alapértelmezett", "confirm_delete": "Törlöd a \"{name}\" naptárat? Az összes esemény eltávolításra kerül.", "confirm_clear": "Törlöd az összes eseményt a \"{name}\" naptárból? Ez nem vonható vissza.", + "set_default": "Beállítás alapértelmezettként", + "default_updated": "Alapértelmezett naptár frissítve", + "error_default": "Nem sikerült beállítani az alapértelmezett naptárat", "clear_events": "Események törlése", "events_cleared": "{count} esemény törölve", "error_clear": "Nem sikerült törölni a naptár eseményeket", diff --git a/locales/it/common.json b/locales/it/common.json index daf2a99b..84398dc7 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1380,6 +1380,17 @@ "copy": "Copia", "copied": "Copiato", "password_instructions": "Usa il tuo nome utente JMAP sopra indicato insieme a una password dell'app per accedere al tuo client e-mail. Crea una password dell'app nella sezione sopra se non l'hai ancora fatto." + }, + "link_device": { + "title": "Collega l'app mobile", + "description": "Accedi all'app mobile Bulwark Mail senza digitare nulla. Genera qui un codice QR e scansionalo dalla schermata di accesso dell'app.", + "generate": "Mostra codice QR", + "regenerate": "Mostra un nuovo codice", + "instructions": "Apri l'app Bulwark Mail, tocca \"Scansiona codice QR\" nella schermata di accesso e inquadra qui con la fotocamera.", + "expires_in": "Questo codice scade tra {seconds} secondi. Può essere usato una sola volta.", + "expired": "Questo codice è scaduto.", + "generating": "Generazione…", + "error": "Impossibile creare un codice di associazione. Riprova." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Ogni {count} settimane", "every_n_months": "Ogni {count} mesi", "until": "Fino al", - "occurrences": "{count} ripetizioni" + "occurrences": "{count} ripetizioni", + "custom": "Personalizzato…", + "edit_custom": "Modifica ricorrenza personalizzata", + "every_n_years": "Ogni {count} anni", + "on_days": "di {days}", + "on_day_n": "il giorno {day}", + "on_the_nth": "il {nth} {day}", + "in_month": "a {month}", + "nth_1": "primo", + "nth_2": "secondo", + "nth_3": "terzo", + "nth_4": "quarto", + "nth_last": "ultimo", + "editor_freq_day": "Giorno", + "editor_freq_week": "Settimana", + "editor_freq_month": "Mese", + "editor_freq_year": "Anno", + "editor_repeats_on": "Si ripete", + "editor_every": "Ogni", + "editor_unit_days": "giorno/i", + "editor_unit_weeks": "settimana/e", + "editor_unit_months": "mese/i", + "editor_unit_years": "anno/i", + "editor_on_day": "il giorno", + "editor_on_the": "il", + "editor_in": "a", + "editor_ends": "Termina", + "editor_never": "Mai", + "editor_ends_on": "Il", + "editor_ends_after": "Dopo", + "editor_occurrences": "occorrenze" }, "recurrence_scope": { "edit_title": "Modifica evento ricorrente", @@ -2662,6 +2703,9 @@ "copy_url": "Copia URL CalDAV", "url_copied": "URL CalDAV copiato negli appunti", "confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.", + "set_default": "Imposta come predefinito", + "default_updated": "Calendario predefinito aggiornato", + "error_default": "Impossibile impostare il calendario predefinito", "clear_events": "Cancella eventi", "events_cleared": "{count} eventi cancellati", "error_clear": "Impossibile cancellare gli eventi del calendario", diff --git a/locales/ja/common.json b/locales/ja/common.json index 35f6fb14..3145ed6c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1380,6 +1380,17 @@ "copy": "コピー", "copied": "コピー済み", "password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、上のセクションでアプリパスワードを作成してください。" + }, + "link_device": { + "title": "モバイルアプリを連携", + "description": "入力なしで Bulwark Mail モバイルアプリにサインインできます。ここで QR コードを生成し、アプリのログイン画面からスキャンしてください。", + "generate": "QRコードを表示", + "regenerate": "新しいコードを表示", + "instructions": "Bulwark Mail アプリを開き、ログイン画面で「QRコードをスキャン」をタップして、カメラをここに向けてください。", + "expires_in": "このコードは{seconds}秒で期限切れになります。一度しか使用できません。", + "expired": "このコードは期限切れです。", + "generating": "生成中…", + "error": "ペアリングコードを作成できませんでした。もう一度お試しください。" } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "{count}週間ごと", "every_n_months": "{count}か月ごと", "until": "終了日", - "occurrences": "{count}回" + "occurrences": "{count}回", + "custom": "カスタム…", + "edit_custom": "カスタム繰り返しを編集", + "every_n_years": "{count}年ごと", + "on_days": "{days}", + "on_day_n": "{day}日", + "on_the_nth": "{nth}{day}", + "in_month": "{month}", + "nth_1": "第1", + "nth_2": "第2", + "nth_3": "第3", + "nth_4": "第4", + "nth_last": "最終", + "editor_freq_day": "日", + "editor_freq_week": "週", + "editor_freq_month": "月", + "editor_freq_year": "年", + "editor_repeats_on": "繰り返し", + "editor_every": "間隔:", + "editor_unit_days": "日ごと", + "editor_unit_weeks": "週間ごと", + "editor_unit_months": "か月ごと", + "editor_unit_years": "年ごと", + "editor_on_day": "日付:", + "editor_on_the": "曜日:", + "editor_in": "月:", + "editor_ends": "終了", + "editor_never": "なし", + "editor_ends_on": "終了日", + "editor_ends_after": "回数", + "editor_occurrences": "回" }, "recurrence_scope": { "edit_title": "繰り返しイベントを編集", @@ -2662,6 +2703,9 @@ "copy_url": "CalDAV URLをコピー", "url_copied": "CalDAV URLをクリップボードにコピーしました", "confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。", + "set_default": "デフォルトに設定", + "default_updated": "デフォルトのカレンダーを更新しました", + "error_default": "デフォルトのカレンダーを設定できませんでした", "clear_events": "イベントを削除", "events_cleared": "{count}件のイベントを削除しました", "error_clear": "カレンダーイベントの削除に失敗しました", diff --git a/locales/ko/common.json b/locales/ko/common.json index 554ca3a4..56cfb729 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1380,6 +1380,17 @@ "copy": "복사", "copied": "복사되었어요", "password_instructions": "위의 JMAP 사용자 이름과 앱 비밀번호를 사용해서 이메일 클라이언트에 로그인하세요. 아직 만들지 않았다면 위에서 앱 비밀번호를 먼저 만들어 주세요." + }, + "link_device": { + "title": "모바일 앱 연결", + "description": "아무것도 입력하지 않고 Bulwark Mail 모바일 앱에 로그인하세요. 여기에서 QR 코드를 생성하고 앱의 로그인 화면에서 스캔하세요.", + "generate": "QR 코드 표시", + "regenerate": "새 코드 표시", + "instructions": "Bulwark Mail 앱을 열고 로그인 화면에서 \"QR 코드 스캔\"을 누른 다음 카메라를 여기에 비추세요.", + "expires_in": "이 코드는 {seconds}초 후에 만료됩니다. 한 번만 사용할 수 있습니다.", + "expired": "이 코드는 만료되었습니다.", + "generating": "생성 중…", + "error": "페어링 코드를 만들지 못했습니다. 다시 시도해 주세요." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "{count}주마다", "every_n_months": "{count}개월마다", "until": "종료일:", - "occurrences": "{count}회 반복" + "occurrences": "{count}회 반복", + "custom": "맞춤…", + "edit_custom": "맞춤 반복 수정", + "every_n_years": "{count}년마다", + "on_days": "{days}", + "on_day_n": "{day}일", + "on_the_nth": "{nth} {day}", + "in_month": "{month}", + "nth_1": "첫째", + "nth_2": "둘째", + "nth_3": "셋째", + "nth_4": "넷째", + "nth_last": "마지막", + "editor_freq_day": "일", + "editor_freq_week": "주", + "editor_freq_month": "월", + "editor_freq_year": "년", + "editor_repeats_on": "반복", + "editor_every": "매", + "editor_unit_days": "일", + "editor_unit_weeks": "주", + "editor_unit_months": "개월", + "editor_unit_years": "년", + "editor_on_day": "날짜:", + "editor_on_the": "요일:", + "editor_in": "월:", + "editor_ends": "종료", + "editor_never": "안 함", + "editor_ends_on": "종료일", + "editor_ends_after": "횟수", + "editor_occurrences": "회" }, "recurrence_scope": { "edit_title": "반복 일정 수정", @@ -2652,6 +2693,9 @@ "default": "기본", "confirm_delete": "\"{name}\" 캘린더를 삭제할까요? 캘린더 안의 모든 일정이 함께 지워져요.", "confirm_clear": "\"{name}\" 캘린더의 모든 일정을 지울까요? 이 작업은 되돌릴 수 없어요.", + "set_default": "기본으로 설정", + "default_updated": "기본 캘린더가 업데이트되었습니다", + "error_default": "기본 캘린더를 설정하지 못했습니다", "clear_events": "일정 모두 지우기", "events_cleared": "{count}개의 일정을 지웠어요", "error_clear": "일정을 지우지 못했어요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 0c8b8b4b..f69d9984 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopēt", "copied": "Nokopēts", "password_instructions": "Izmantojiet norādīto JMAP lietotājvārdu kopā ar lietotnes paroli. Izveidojiet lietotnes paroli sadaļā augstāk." + }, + "link_device": { + "title": "Saistīt mobilo lietotni", + "description": "Pierakstieties Bulwark Mail mobilajā lietotnē, neko nerakstot. Šeit izveidojiet QR kodu un noskenējiet to lietotnes pierakstīšanās ekrānā.", + "generate": "Rādīt QR kodu", + "regenerate": "Rādīt jaunu kodu", + "instructions": "Atveriet Bulwark Mail lietotni, pierakstīšanās ekrānā pieskarieties \"Skenēt QR kodu\" un pavērsiet kameru šeit.", + "expires_in": "Šī koda derīgums beigsies pēc {seconds} sekundēm. To var izmantot tikai vienu reizi.", + "expired": "Šī koda derīgums ir beidzies.", + "generating": "Ģenerē…", + "error": "Neizdevās izveidot pārošanas kodu. Lūdzu, mēģiniet vēlreiz." } }, "identities": { @@ -2489,7 +2500,37 @@ "every_n_weeks": "Ik pēc {count} nedēļām", "every_n_months": "Ik pēc {count} mēnešiem", "until": "Līdz", - "occurrences": "{count} reizes" + "occurrences": "{count} reizes", + "custom": "Pielāgots…", + "edit_custom": "Rediģēt pielāgotu atkārtošanos", + "every_n_years": "Ik pēc {count} gadiem", + "on_days": "šajās dienās: {days}", + "on_day_n": "mēneša {day}. dienā", + "on_the_nth": "{nth} {day}", + "in_month": "{month} mēnesī", + "nth_1": "pirmajā", + "nth_2": "otrajā", + "nth_3": "trešajā", + "nth_4": "ceturtajā", + "nth_last": "pēdējā", + "editor_freq_day": "Diena", + "editor_freq_week": "Nedēļa", + "editor_freq_month": "Mēnesis", + "editor_freq_year": "Gads", + "editor_repeats_on": "Atkārtojas", + "editor_every": "Ik pēc", + "editor_unit_days": "dienām", + "editor_unit_weeks": "nedēļām", + "editor_unit_months": "mēnešiem", + "editor_unit_years": "gadiem", + "editor_on_day": "mēneša dienā", + "editor_on_the": "šajā:", + "editor_in": "mēnesī", + "editor_ends": "Beidzas", + "editor_never": "Nekad", + "editor_ends_on": "Datumā", + "editor_ends_after": "Pēc", + "editor_occurrences": "reizēm" }, "recurrence_scope": { "edit_title": "Rediģēt atkārtotu pasākumu", @@ -2651,6 +2692,9 @@ "default": "Noklusējuma", "confirm_delete": "Dzēst «{name}»? Visi pasākumi šajā kalendārā tiks dzēsti.", "confirm_clear": "Iztīrīt visus pasākumus no «{name}»? Šo darbību nevar atcelt.", + "set_default": "Iestatīt kā noklusējumu", + "default_updated": "Noklusējuma kalendārs atjaunināts", + "error_default": "Neizdevās iestatīt noklusējuma kalendāru", "clear_events": "Iztīrīt pasākumus", "events_cleared": "Iztīrīti {count} pasākumi", "error_clear": "Neizdevās iztīrīt kalendāra pasākumus", diff --git a/locales/nl/common.json b/locales/nl/common.json index 6a7f02f7..4412ccbd 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopiëren", "copied": "Gekopieerd", "password_instructions": "Gebruik uw hierboven vermelde JMAP-gebruikersnaam samen met een app-wachtwoord om in te loggen bij uw e-mailclient. Maak een app-wachtwoord aan in het bovenstaande gedeelte als u dat nog niet heeft gedaan." + }, + "link_device": { + "title": "Mobiele app koppelen", + "description": "Log in bij de Bulwark Mail mobiele app zonder iets te typen. Genereer hier een QR-code en scan deze vanaf het inlogscherm van de app.", + "generate": "QR-code tonen", + "regenerate": "Nieuwe code tonen", + "instructions": "Open de Bulwark Mail app, tik op \"QR-code scannen\" op het inlogscherm en richt uw camera hierop.", + "expires_in": "Deze code verloopt over {seconds} seconden. Hij kan maar één keer worden gebruikt.", + "expired": "Deze code is verlopen.", + "generating": "Genereren…", + "error": "Kon geen koppelingscode aanmaken. Probeer het opnieuw." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Elke {count} weken", "every_n_months": "Elke {count} maanden", "until": "Tot", - "occurrences": "{count} herhalingen" + "occurrences": "{count} herhalingen", + "custom": "Aangepast…", + "edit_custom": "Aangepaste herhaling bewerken", + "every_n_years": "Elke {count} jaar", + "on_days": "op {days}", + "on_day_n": "op dag {day}", + "on_the_nth": "op de {nth} {day}", + "in_month": "in {month}", + "nth_1": "eerste", + "nth_2": "tweede", + "nth_3": "derde", + "nth_4": "vierde", + "nth_last": "laatste", + "editor_freq_day": "Dag", + "editor_freq_week": "Week", + "editor_freq_month": "Maand", + "editor_freq_year": "Jaar", + "editor_repeats_on": "Herhaalt zich", + "editor_every": "Elke", + "editor_unit_days": "dag(en)", + "editor_unit_weeks": "week/weken", + "editor_unit_months": "maand(en)", + "editor_unit_years": "jaar/jaren", + "editor_on_day": "op dag", + "editor_on_the": "op de", + "editor_in": "in", + "editor_ends": "Eindigt", + "editor_never": "Nooit", + "editor_ends_on": "Op", + "editor_ends_after": "Na", + "editor_occurrences": "herhalingen" }, "recurrence_scope": { "edit_title": "Terugkerend evenement bewerken", @@ -2662,6 +2703,9 @@ "copy_url": "CalDAV-URL kopiëren", "url_copied": "CalDAV-URL gekopieerd naar klembord", "confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.", + "set_default": "Als standaard instellen", + "default_updated": "Standaardagenda bijgewerkt", + "error_default": "Standaardagenda instellen mislukt", "clear_events": "Afspraken verwijderen", "events_cleared": "{count} afspraken verwijderd", "error_clear": "Kan agendagebeurtenissen niet verwijderen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 877caccf..17804a80 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopiuj", "copied": "Skopiowano", "password_instructions": "Użyj powyższej nazwy użytkownika JMAP wraz z hasłem aplikacji, aby zalogować się w kliencie poczty. Utwórz hasło aplikacji w sekcji powyżej, jeśli jeszcze tego nie zrobiłeś." + }, + "link_device": { + "title": "Połącz aplikację mobilną", + "description": "Zaloguj się do aplikacji mobilnej Bulwark Mail bez wpisywania czegokolwiek. Wygeneruj tutaj kod QR i zeskanuj go na ekranie logowania aplikacji.", + "generate": "Pokaż kod QR", + "regenerate": "Pokaż nowy kod", + "instructions": "Otwórz aplikację Bulwark Mail, na ekranie logowania stuknij \"Skanuj kod QR\" i skieruj aparat tutaj.", + "expires_in": "Ten kod wygaśnie za {seconds} sekund. Można go użyć tylko raz.", + "expired": "Ten kod wygasł.", + "generating": "Generowanie…", + "error": "Nie udało się utworzyć kodu parowania. Spróbuj ponownie." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Co {count} tygodni", "every_n_months": "Co {count} miesięcy", "until": "Do", - "occurrences": "{count} wystąpień" + "occurrences": "{count} wystąpień", + "custom": "Niestandardowe…", + "edit_custom": "Edytuj niestandardowe powtarzanie", + "every_n_years": "Co {count} lat", + "on_days": "w {days}", + "on_day_n": "{day}. dnia miesiąca", + "on_the_nth": "w {nth} {day}", + "in_month": "w {month}", + "nth_1": "pierwszy", + "nth_2": "drugi", + "nth_3": "trzeci", + "nth_4": "czwarty", + "nth_last": "ostatni", + "editor_freq_day": "Dzień", + "editor_freq_week": "Tydzień", + "editor_freq_month": "Miesiąc", + "editor_freq_year": "Rok", + "editor_repeats_on": "Powtarza się", + "editor_every": "Co", + "editor_unit_days": "dni", + "editor_unit_weeks": "tyg.", + "editor_unit_months": "mies.", + "editor_unit_years": "lat(a)", + "editor_on_day": "dnia", + "editor_on_the": "w", + "editor_in": "w miesiącu", + "editor_ends": "Kończy się", + "editor_never": "Nigdy", + "editor_ends_on": "Dnia", + "editor_ends_after": "Po", + "editor_occurrences": "wystąpieniach" }, "recurrence_scope": { "edit_title": "Edytuj wydarzenie cykliczne", @@ -2652,6 +2693,9 @@ "default": "Domyślny", "confirm_delete": "Usunąć \"{name}\"? Wszystkie wydarzenia w tym kalendarzu zostaną usunięte.", "confirm_clear": "Wyczyścić wszystkie wydarzenia z \"{name}\"? Tej operacji nie można cofnąć.", + "set_default": "Ustaw jako domyślny", + "default_updated": "Zaktualizowano domyślny kalendarz", + "error_default": "Nie udało się ustawić domyślnego kalendarza", "clear_events": "Wyczyść wydarzenia", "events_cleared": "Wyczyszczono {count} wydarzeń", "error_clear": "Nie udało się wyczyścić wydarzeń kalendarza", diff --git a/locales/pt/common.json b/locales/pt/common.json index 1c2b6a1e..c78fd3d0 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1380,6 +1380,17 @@ "copy": "Copiar", "copied": "Copiado", "password_instructions": "Use seu nome de usuário JMAP acima junto com uma senha de aplicativo para entrar no seu cliente de e-mail. Crie uma senha de aplicativo na seção acima se ainda não tiver uma." + }, + "link_device": { + "title": "Vincular aplicativo móvel", + "description": "Entre no aplicativo móvel Bulwark Mail sem digitar nada. Gere um código QR aqui e escaneie-o na tela de login do aplicativo.", + "generate": "Mostrar código QR", + "regenerate": "Mostrar um novo código", + "instructions": "Abra o aplicativo Bulwark Mail, toque em \"Escanear código QR\" na tela de login e aponte sua câmera para cá.", + "expires_in": "Este código expira em {seconds} segundos. Ele só pode ser usado uma vez.", + "expired": "Este código expirou.", + "generating": "Gerando…", + "error": "Não foi possível criar um código de pareamento. Tente novamente." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "A cada {count} semanas", "every_n_months": "A cada {count} meses", "until": "Até", - "occurrences": "{count} repetições" + "occurrences": "{count} repetições", + "custom": "Personalizado…", + "edit_custom": "Editar recorrência personalizada", + "every_n_years": "A cada {count} anos", + "on_days": "em {days}", + "on_day_n": "no dia {day}", + "on_the_nth": "no {nth} {day}", + "in_month": "em {month}", + "nth_1": "1.º", + "nth_2": "2.º", + "nth_3": "3.º", + "nth_4": "4.º", + "nth_last": "último", + "editor_freq_day": "Dia", + "editor_freq_week": "Semana", + "editor_freq_month": "Mês", + "editor_freq_year": "Ano", + "editor_repeats_on": "Repete-se", + "editor_every": "A cada", + "editor_unit_days": "dia(s)", + "editor_unit_weeks": "semana(s)", + "editor_unit_months": "mês(es)", + "editor_unit_years": "ano(s)", + "editor_on_day": "no dia", + "editor_on_the": "no", + "editor_in": "em", + "editor_ends": "Termina", + "editor_never": "Nunca", + "editor_ends_on": "Em", + "editor_ends_after": "Após", + "editor_occurrences": "repetições" }, "recurrence_scope": { "edit_title": "Editar evento recorrente", @@ -2676,6 +2717,9 @@ "copy_url": "Copiar URL CalDAV", "url_copied": "URL CalDAV copiada para a área de transferência", "confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.", + "set_default": "Definir como padrão", + "default_updated": "Calendário padrão atualizado", + "error_default": "Falha ao definir calendário padrão", "clear_events": "Limpar eventos", "events_cleared": "{count} eventos removidos", "error_clear": "Falha ao limpar os eventos do calendário", diff --git a/locales/ru/common.json b/locales/ru/common.json index fc2225cf..e62089fd 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1380,6 +1380,17 @@ "copy": "Копировать", "copied": "Скопировано", "password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе выше, если вы ещё этого не сделали." + }, + "link_device": { + "title": "Привязать мобильное приложение", + "description": "Войдите в мобильное приложение Bulwark Mail без ввода данных. Создайте здесь QR-код и отсканируйте его на экране входа в приложении.", + "generate": "Показать QR-код", + "regenerate": "Показать новый код", + "instructions": "Откройте приложение Bulwark Mail, нажмите \"Сканировать QR-код\" на экране входа и наведите камеру сюда.", + "expires_in": "Срок действия кода истечёт через {seconds} секунд. Его можно использовать только один раз.", + "expired": "Срок действия кода истёк.", + "generating": "Создание…", + "error": "Не удалось создать код привязки. Пожалуйста, попробуйте ещё раз." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Каждые {count} недель", "every_n_months": "Каждые {count} месяцев", "until": "До", - "occurrences": "{count} повторений" + "occurrences": "{count} повторений", + "custom": "Свой вариант…", + "edit_custom": "Изменить настраиваемое повторение", + "every_n_years": "Каждые {count} лет", + "on_days": "в {days}", + "on_day_n": "{day}-го числа", + "on_the_nth": "в {nth} {day}", + "in_month": "в {month}", + "nth_1": "первый", + "nth_2": "второй", + "nth_3": "третий", + "nth_4": "четвёртый", + "nth_last": "последний", + "editor_freq_day": "День", + "editor_freq_week": "Неделя", + "editor_freq_month": "Месяц", + "editor_freq_year": "Год", + "editor_repeats_on": "Повторяется", + "editor_every": "Каждые", + "editor_unit_days": "дн.", + "editor_unit_weeks": "нед.", + "editor_unit_months": "мес.", + "editor_unit_years": "г.", + "editor_on_day": "в день", + "editor_on_the": "в", + "editor_in": "в месяце", + "editor_ends": "Заканчивается", + "editor_never": "Никогда", + "editor_ends_on": "Дата", + "editor_ends_after": "После", + "editor_occurrences": "повторений" }, "recurrence_scope": { "edit_title": "Редактировать повторяющееся событие", @@ -2652,6 +2693,9 @@ "default": "По умолчанию", "confirm_delete": "Удалить «{name}»? Все события в этом календаре будут удалены.", "confirm_clear": "Очистить все события из «{name}»? Это действие нельзя отменить.", + "set_default": "Сделать по умолчанию", + "default_updated": "Календарь по умолчанию обновлён", + "error_default": "Не удалось установить календарь по умолчанию", "clear_events": "Очистить события", "events_cleared": "{count} событий очищено", "error_clear": "Не удалось очистить события календаря", diff --git a/locales/tr/common.json b/locales/tr/common.json index dbf12d53..7d3fd075 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopyala", "copied": "Kopyalandı", "password_instructions": "E-posta istemcinizde oturum açmak için yukarıdaki JMAP kullanıcı adı ile bir uygulama parolasını birlikte kullanın. Henüz oluşturmadıysanız yukarıdaki bölümden bir uygulama parolası oluşturun." + }, + "link_device": { + "title": "Mobil uygulamayı bağla", + "description": "Hiçbir şey yazmadan Bulwark Mail mobil uygulamasında oturum açın. Burada bir QR kodu oluşturun ve uygulamanın giriş ekranından tarayın.", + "generate": "QR kodunu göster", + "regenerate": "Yeni kod göster", + "instructions": "Bulwark Mail uygulamasını açın, giriş ekranında \"QR kodu tara\" seçeneğine dokunun ve kameranızı buraya doğrultun.", + "expires_in": "Bu kodun süresi {seconds} saniye içinde dolacak. Yalnızca bir kez kullanılabilir.", + "expired": "Bu kodun süresi doldu.", + "generating": "Oluşturuluyor…", + "error": "Eşleştirme kodu oluşturulamadı. Lütfen tekrar deneyin." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Her {count} haftada bir", "every_n_months": "Her {count} ayda bir", "until": "Şu tarihe kadar", - "occurrences": "{count} tekrar" + "occurrences": "{count} tekrar", + "custom": "Özel…", + "edit_custom": "Özel yinelemeyi düzenle", + "every_n_years": "Her {count} yılda bir", + "on_days": "{days} günleri", + "on_day_n": "ayın {day}. günü", + "on_the_nth": "{nth} {day} günü", + "in_month": "{month} ayında", + "nth_1": "ilk", + "nth_2": "ikinci", + "nth_3": "üçüncü", + "nth_4": "dördüncü", + "nth_last": "son", + "editor_freq_day": "Gün", + "editor_freq_week": "Hafta", + "editor_freq_month": "Ay", + "editor_freq_year": "Yıl", + "editor_repeats_on": "Yinelenme", + "editor_every": "Her", + "editor_unit_days": "günde bir", + "editor_unit_weeks": "haftada bir", + "editor_unit_months": "ayda bir", + "editor_unit_years": "yılda bir", + "editor_on_day": "ayın şu günü:", + "editor_on_the": "şu gün:", + "editor_in": "ay:", + "editor_ends": "Bitiş", + "editor_never": "Asla", + "editor_ends_on": "Tarihinde", + "editor_ends_after": "Sonra:", + "editor_occurrences": "tekrar" }, "recurrence_scope": { "edit_title": "Yinelenen etkinliği düzenle", @@ -2666,6 +2707,9 @@ "default": "Varsayılan", "confirm_delete": "\"{name}\" silinsin mi? Bu takvimdeki tüm etkinlikler kaldırılacak.", "confirm_clear": "\"{name}\" takvimdeki tüm etkinlikler temizlensin mi? Bu geri alınamaz.", + "set_default": "Varsayılan olarak ayarla", + "default_updated": "Varsayılan takvim güncellendi", + "error_default": "Varsayılan takvim ayarlanamadı", "clear_events": "Etkinlikleri temizle", "events_cleared": "{count} etkinlik temizlendi", "error_clear": "Takvim etkinlikleri temizlenemedi", diff --git a/locales/uk/common.json b/locales/uk/common.json index 86cf9001..37d1d9a6 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1380,6 +1380,17 @@ "copy": "Копія", "copied": "Скопійовано", "password_instructions": "Використовуйте своє ім’я користувача JMAP вище разом із паролем програми, щоб увійти у свій поштовий клієнт. Створіть пароль програми в розділі вище, якщо ви ще цього не зробили." + }, + "link_device": { + "title": "Прив'язати мобільний застосунок", + "description": "Увійдіть у мобільний застосунок Bulwark Mail без введення даних. Створіть тут QR-код і відскануйте його на екрані входу в застосунку.", + "generate": "Показати QR-код", + "regenerate": "Показати новий код", + "instructions": "Відкрийте застосунок Bulwark Mail, натисніть \"Сканувати QR-код\" на екрані входу та наведіть камеру сюди.", + "expires_in": "Термін дії коду закінчиться через {seconds} секунд. Його можна використати лише один раз.", + "expired": "Термін дії цього коду закінчився.", + "generating": "Створення…", + "error": "Не вдалося створити код прив'язки. Будь ласка, спробуйте ще раз." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Кожні {count} тижнів", "every_n_months": "Кожні {count} місяців", "until": "Поки", - "occurrences": "{count} випадків" + "occurrences": "{count} випадків", + "custom": "Власний…", + "edit_custom": "Змінити власне повторення", + "every_n_years": "Кожні {count} років", + "on_days": "у {days}", + "on_day_n": "{day}-го числа", + "on_the_nth": "у {nth} {day}", + "in_month": "у {month}", + "nth_1": "перший", + "nth_2": "другий", + "nth_3": "третій", + "nth_4": "четвертий", + "nth_last": "останній", + "editor_freq_day": "День", + "editor_freq_week": "Тиждень", + "editor_freq_month": "Місяць", + "editor_freq_year": "Рік", + "editor_repeats_on": "Повторюється", + "editor_every": "Кожні", + "editor_unit_days": "дн.", + "editor_unit_weeks": "тиж.", + "editor_unit_months": "міс.", + "editor_unit_years": "р.", + "editor_on_day": "у день", + "editor_on_the": "у", + "editor_in": "у місяці", + "editor_ends": "Закінчується", + "editor_never": "Ніколи", + "editor_ends_on": "Дата", + "editor_ends_after": "Після", + "editor_occurrences": "повторень" }, "recurrence_scope": { "edit_title": "Редагувати повторювану подію", @@ -2652,6 +2693,9 @@ "default": "За замовчуванням", "confirm_delete": "Видалити \"{name}\"? Усі події в цьому календарі буде видалено.", "confirm_clear": "Очистити всі події з \"{name}\"? Це неможливо скасувати.", + "set_default": "Зробити типовим", + "default_updated": "Типовий календар оновлено", + "error_default": "Не вдалося встановити типовий календар", "clear_events": "Ясні події", "events_cleared": "{count} подій видалено", "error_clear": "Не вдалося очистити події календаря", diff --git a/locales/zh/common.json b/locales/zh/common.json index c5f05237..feb684df 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1380,6 +1380,17 @@ "copy": "复制", "copied": "已复制", "password_instructions": "使用上面的 JMAP 用户名和应用密码登录您的邮件客户端。如果您还没有创建应用密码,请在上面的部分中创建。" + }, + "link_device": { + "title": "关联移动应用", + "description": "无需输入任何内容即可登录 Bulwark Mail 移动应用。在此生成二维码,然后在应用的登录界面扫描。", + "generate": "显示二维码", + "regenerate": "显示新的二维码", + "instructions": "打开 Bulwark Mail 应用,在登录界面点按\"扫描二维码\",然后将相机对准此处。", + "expires_in": "此二维码将在 {seconds} 秒后过期,且只能使用一次。", + "expired": "此二维码已过期。", + "generating": "生成中…", + "error": "无法创建配对码。请重试。" } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "每 {count} 周", "every_n_months": "每 {count} 个月", "until": "直到", - "occurrences": "{count} 次出现" + "occurrences": "{count} 次出现", + "custom": "自定义…", + "edit_custom": "编辑自定义重复规则", + "every_n_years": "每 {count} 年", + "on_days": "{days}", + "on_day_n": "{day}日", + "on_the_nth": "{nth}{day}", + "in_month": "{month}", + "nth_1": "第一个", + "nth_2": "第二个", + "nth_3": "第三个", + "nth_4": "第四个", + "nth_last": "最后一个", + "editor_freq_day": "天", + "editor_freq_week": "周", + "editor_freq_month": "月", + "editor_freq_year": "年", + "editor_repeats_on": "重复于", + "editor_every": "每", + "editor_unit_days": "天", + "editor_unit_weeks": "周", + "editor_unit_months": "个月", + "editor_unit_years": "年", + "editor_on_day": "日期:", + "editor_on_the": "在", + "editor_in": "月份:", + "editor_ends": "结束", + "editor_never": "永不", + "editor_ends_on": "于", + "editor_ends_after": "次数:", + "editor_occurrences": "次" }, "recurrence_scope": { "edit_title": "编辑重复事件", @@ -2652,6 +2693,9 @@ "default": "默认", "confirm_delete": "删除\"{name}\"?此日历中的所有活动都将被删除。", "confirm_clear": "清除\"{name}\"中的所有活动吗?此操作无法撤销。", + "set_default": "设为默认", + "default_updated": "默认日历已更新", + "error_default": "无法设置默认日历", "clear_events": "清除活动", "events_cleared": "{count} 个活动已清除", "error_clear": "无法清除日历活动", diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 7040d55e..5ccaf043 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -232,6 +232,7 @@ interface CalendarStore { rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record | null) => Promise; importEvents: (client: IJMAPClient, events: Partial[], calendarId: string) => Promise; updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial) => Promise; + setDefaultCalendar: (client: IJMAPClient, calendarId: string) => Promise; shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise; createCalendar: (client: IJMAPClient, calendar: Partial) => Promise; removeCalendar: (client: IJMAPClient, calendarId: string) => Promise; @@ -549,9 +550,10 @@ export const useCalendarStore = create()( rsvpEvent: async (client, eventId, participantId, status, replyTo) => { set({ error: null }); - // JMAP participant IDs are opaque strings - they can contain @, ., :, / etc. - // Only reject empty or obviously malicious values (path traversal). - if (!participantId || participantId.includes('..')) { + // JMAP participant IDs are opaque strings - they can contain @, ., :, + // / etc. The id is RFC 6901-escaped below before being embedded in the + // patch pointer, so any character is safe; only reject empty values. + if (!participantId) { set({ error: 'Invalid participant ID' }); throw new Error('Invalid participant ID'); } @@ -815,6 +817,36 @@ export const useCalendarStore = create()( } }, + setDefaultCalendar: async (client, calendarId) => { + set({ error: null }); + try { + const cal = get().calendars.find(c => c.id === calendarId); + const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); + const targetAccountId = cal?.accountId; + client = resolveAccountClient(client, cal?.localAccountId); + await client.setDefaultCalendar(realId, targetAccountId); + set((state) => ({ + calendars: state.calendars.map(c => { + if (c.id === calendarId) return { ...c, isDefault: true }; + // Only one default per account - clear the flag on siblings of + // the same local account / shared-account scope. + if ( + c.isDefault + && (c.localAccountId ?? null) === (cal?.localAccountId ?? null) + && (c.accountId ?? null) === (cal?.accountId ?? null) + ) { + return { ...c, isDefault: false }; + } + return c; + }), + })); + } catch (error) { + debug.error('Failed to set default calendar:', error); + set({ error: 'Failed to set default calendar' }); + throw error; + } + }, + shareCalendar: async (client, calendarId, principalId, rights) => { set({ error: null }); try {