From 3e1de10213b498b5016870816c656a6ee5dee997 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:10:04 +0200 Subject: [PATCH] feat: add JMAP sharing for calendars and address books --- app/[locale]/calendar/page.tsx | 83 ++++- app/[locale]/contacts/page.tsx | 48 ++- .../calendar/calendar-sidebar-panel.tsx | 232 ++++++------ components/calendar/create-calendar-modal.tsx | 151 ++++++++ components/calendar/event-modal.tsx | 3 + components/contacts/contact-form.tsx | 8 +- components/contacts/contacts-sidebar.tsx | 87 ++++- .../address-book-management-settings.tsx | 36 +- .../settings/calendar-management-settings.tsx | 37 +- .../settings/share-collection-dialog.tsx | 347 ++++++++++++++++++ lib/__tests__/calendar-alerts.test.ts | 2 +- lib/birthday-calendar.ts | 2 +- lib/demo/demo-client.ts | 11 +- lib/demo/fixtures/calendars.ts | 6 +- lib/jmap/client-interface.ts | 9 +- lib/jmap/client.ts | 102 ++++- lib/jmap/types.ts | 17 +- locales/cs/common.json | 57 ++- locales/de/common.json | 35 +- locales/en/common.json | 35 +- locales/es/common.json | 35 +- locales/fr/common.json | 35 +- locales/it/common.json | 35 +- locales/ja/common.json | 35 +- locales/ko/common.json | 35 +- locales/lv/common.json | 35 +- locales/nl/common.json | 35 +- locales/pl/common.json | 35 +- locales/pt/common.json | 35 +- locales/ru/common.json | 35 +- locales/uk/common.json | 35 +- locales/zh/common.json | 35 +- stores/calendar-store.ts | 26 +- stores/contact-store.ts | 43 ++- 34 files changed, 1605 insertions(+), 192 deletions(-) create mode 100644 components/calendar/create-calendar-modal.tsx create mode 100644 components/settings/share-collection-dialog.tsx diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 1b67de9c..0bcad9a8 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -47,7 +47,11 @@ import { getEventStartDate } from "@/lib/calendar-utils"; import { useTaskStore } from "@/stores/task-store"; import { useContactStore } from "@/stores/contact-store"; import { cn } from "@/lib/utils"; -import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; +import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types"; +import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal"; import { getUserParticipantId } from "@/lib/calendar-participants"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { debug } from "@/lib/debug"; @@ -72,7 +76,8 @@ export default function CalendarPage() { calendars, events, selectedDate, viewMode, selectedCalendarIds, isLoading, isLoadingEvents, supportsCalendar, error, fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent, - setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, + setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar, + removeCalendar, clearCalendarEvents, refreshAllSubscriptions, icalSubscriptions, } = useCalendarStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore(); @@ -91,6 +96,11 @@ export default function CalendarPage() { const [showImportModal, setShowImportModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [editingSubscription, setEditingSubscription] = useState(null); + const [sharingCalendarId, setSharingCalendarId] = useState(null); + const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState(undefined); + const [showCreateCalendar, setShowCreateCalendar] = useState(false); + const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog(); + const tMgmt = useTranslations("calendar.management"); const [editEvent, setEditEvent] = useState(null); const [defaultModalDate, setDefaultModalDate] = useState(); const [defaultModalEndDate, setDefaultModalEndDate] = useState(); @@ -1102,6 +1112,42 @@ export default function CalendarPage() { } updateCalendar(client, calendarId, { color }); } : undefined} + onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined} + onCreateEvent={(cal: Calendar) => { + setDefaultCalendarIdForCreate(cal.id); + openCreateModal(); + }} + onClearCalendar={client ? async (cal: Calendar) => { + const ok = await confirmAction({ + title: tMgmt("clear_events"), + message: tMgmt("confirm_clear", { name: cal.name }), + variant: "destructive", + confirmText: tMgmt("clear_events"), + }); + if (!ok) return; + try { + const count = await clearCalendarEvents(client, cal.id); + toast.success(tMgmt("events_cleared", { count })); + } catch { + toast.error(tMgmt("error_clear")); + } + } : undefined} + onDeleteCalendar={client ? async (cal: Calendar) => { + const ok = await confirmAction({ + title: tMgmt("delete"), + message: tMgmt("confirm_delete", { name: cal.name }), + variant: "destructive", + confirmText: tMgmt("delete"), + }); + if (!ok) return; + try { + await removeCalendar(client, cal.id); + toast.success(tMgmt("calendar_deleted")); + } catch { + toast.error(tMgmt("error_delete")); + } + } : undefined} + onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined} onSubscribe={() => setShowSubscriptionModal(true)} onEditSubscription={(subId) => setEditingSubscription(subId)} client={client} @@ -1165,11 +1211,12 @@ export default function CalendarPage() { calendars={calendars} defaultDate={defaultModalDate} defaultEndDate={defaultModalEndDate} + defaultCalendarId={defaultCalendarIdForCreate} onSave={handleSaveEvent} onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} - onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }} onPreviewChange={setPendingPreview} currentUserEmails={currentUserEmails} isMobile={false} @@ -1261,11 +1308,12 @@ export default function CalendarPage() { calendars={calendars} defaultDate={defaultModalDate} defaultEndDate={defaultModalEndDate} + defaultCalendarId={defaultCalendarIdForCreate} onSave={handleSaveEvent} onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} - onClose={() => { setShowEventModal(false); setEditEvent(null); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }} currentUserEmails={currentUserEmails} isMobile={true} /> @@ -1305,6 +1353,33 @@ export default function CalendarPage() { onSelect={handleScopeSelect} onClose={() => setPendingScopeAction(null)} /> + + + + {showCreateCalendar && client && ( + setShowCreateCalendar(false)} + /> + )} + + {sharingCalendarId && client && (() => { + const cal = allCalendars.find((c) => c.id === sharingCalendarId); + if (!cal) return null; + return ( + { + await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null); + }} + onClose={() => setSharingCalendarId(null)} + /> + ); + })()} ); } diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 1190019f..8bcc3891 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { useIsMobile } from "@/hooks/use-media-query"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; -import type { ContactCard, AddressBook } from "@/lib/jmap/types"; +import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types"; +import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; type View = | "list" @@ -75,6 +76,8 @@ export default function ContactsPage() { bulkAddToGroup, moveContactToAddressBook, renameAddressBook, + removeAddressBook, + shareAddressBook, renameKeyword, importContacts, } = useContactStore(); @@ -83,6 +86,8 @@ export default function ContactsPage() { const [activeCategory, setActiveCategory] = useState("all"); const [showImportDialog, setShowImportDialog] = useState(false); const [renamingAddressBook, setRenamingAddressBook] = useState(null); + const [sharingAddressBookId, setSharingAddressBookId] = useState(null); + const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState(undefined); const [renamingKeyword, setRenamingKeyword] = useState(null); const [selectedGroupId, setSelectedGroupId] = useState(null); const hasFetched = useRef(false); @@ -329,6 +334,7 @@ export default function ContactsPage() { addLocalContact(localContact); toast.success(t("toast.created")); } + setDefaultBookIdForCreate(undefined); setView("list"); }, [supportsSync, client, createContact, addLocalContact, t]); @@ -346,6 +352,7 @@ export default function ContactsPage() { }, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]); const handleCancel = () => { + setDefaultBookIdForCreate(undefined); if (view === "group-create" || view === "group-edit") { setView(selectedGroup ? "group-detail" : "list"); } else if (view === "bulk-add-to-group") { @@ -517,7 +524,7 @@ export default function ContactsPage() { const renderRightPanel = () => { switch (view) { case "create": - return ; + return ; case "edit": if (!selectedContact) return null; @@ -690,6 +697,26 @@ export default function ContactsPage() { onDropContacts={handleDropContacts} onDropContactsToCategory={handleDropContactsToCategory} onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined} + onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined} + onCreateContactInBook={(book) => { + setDefaultBookIdForCreate(book.id); + handleCreateNew(); + }} + onDeleteAddressBook={client ? async (book) => { + const ok = await confirmDialog({ + title: t("address_books.delete"), + message: t("address_books.confirm_delete", { name: book.name }), + variant: "destructive", + confirmText: t("address_books.delete"), + }); + if (!ok) return; + try { + await removeAddressBook(client, book); + toast.success(t("address_books.deleted")); + } catch { + toast.error(t("address_books.delete_failed")); + } + } : undefined} onRenameKeyword={(kw) => setRenamingKeyword(kw)} /> @@ -838,6 +865,23 @@ export default function ContactsPage() { )} + {sharingAddressBookId && client && (() => { + const book = addressBooks.find((b) => b.id === sharingAddressBookId); + if (!book) return null; + return ( + { + await shareAddressBook(client, book, principalId, rights as AddressBookRights | null); + }} + onClose={() => setSharingAddressBookId(null)} + /> + ); + })()} ); } diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 6355e43b..7c22a2f5 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useRef, useEffect, useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; -import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react"; +import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react"; import { cn, formatDateTime } from "@/lib/utils"; import type { Calendar } from "@/lib/jmap/types"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; @@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useTaskStore } from "@/stores/task-store"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { toast } from "@/stores/toast-store"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; interface CalendarSidebarPanelProps { @@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps { selectedCalendarIds: string[]; onToggleVisibility: (id: string) => void; onColorChange?: (calendarId: string, color: string) => void; + onShareCalendar?: (calendar: Calendar) => void; + onCreateEvent?: (calendar: Calendar) => void; + onClearCalendar?: (calendar: Calendar) => void; + onDeleteCalendar?: (calendar: Calendar) => void; + onCreateCalendar?: () => void; onSubscribe?: () => void; onEditSubscription?: (subscriptionId: string) => void; client?: IJMAPClient | null; @@ -28,12 +35,18 @@ export function CalendarSidebarPanel({ selectedCalendarIds, onToggleVisibility, onColorChange, + onShareCalendar, + onCreateEvent, + onClearCalendar, + onDeleteCalendar, + onCreateCalendar, onSubscribe, onEditSubscription, client, }: CalendarSidebarPanelProps) { const t = useTranslations("calendar"); const tSub = useTranslations("calendar.subscription"); + const tMgmt = useTranslations("calendar.management"); const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions); const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); @@ -49,11 +62,8 @@ export function CalendarSidebarPanel({ return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length; }, [tasks]); - const [colorPickerId, setColorPickerId] = useState(null); - const [contextMenuCalId, setContextMenuCalId] = useState(null); + const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const [refreshingSubId, setRefreshingSubId] = useState(null); - const colorPickerRef = useRef(null); - const contextMenuRef = useRef(null); const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]); const sharedAccountGroups = useMemo(() => { @@ -69,30 +79,6 @@ export function CalendarSidebarPanel({ return Array.from(groups.values()); }, [calendars]); - useEffect(() => { - if (!colorPickerId && !contextMenuCalId) return; - const handleClick = (e: MouseEvent) => { - if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) { - setColorPickerId(null); - } - if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { - setContextMenuCalId(null); - } - }; - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setColorPickerId(null); - setContextMenuCalId(null); - } - }; - document.addEventListener('mousedown', handleClick); - document.addEventListener('keydown', handleKey); - return () => { - document.removeEventListener('mousedown', handleClick); - document.removeEventListener('keydown', handleKey); - }; - }, [colorPickerId, contextMenuCalId]); - const getSubscriptionForCalendar = (calendarId: string) => { return icalSubscriptions.find(s => s.calendarId === calendarId); }; @@ -100,7 +86,6 @@ export function CalendarSidebarPanel({ const handleRefreshSubscription = async (subId: string) => { if (!client) return; setRefreshingSubId(subId); - setContextMenuCalId(null); try { await refreshICalSubscription(client, subId); toast.success(tSub('refresh_success')); @@ -113,7 +98,6 @@ export function CalendarSidebarPanel({ const handleUnsubscribe = async (subId: string) => { if (!client) return; - setContextMenuCalId(null); try { await removeICalSubscription(client, subId); toast.success(tSub('deleted')); @@ -127,21 +111,13 @@ export function CalendarSidebarPanel({ const renderCalendarItem = (cal: Calendar) => { const isVisible = selectedCalendarIds.includes(cal.id); const color = cal.color || "#3b82f6"; + const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true; return (
- - {/* Subscription context menu on right-click */} - {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { - const sub = getSubscriptionForCalendar(cal.id); - if (!sub) return null; - return ( -
- - - - {sub.lastRefreshed && ( -
- {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })} -
- )} -
- ); - })()} - - {/* Color picker popover on right-click */} - {colorPickerId === cal.id && onColorChange && ( -
-

{t("management.change_color")}

- { - onColorChange(cal.id, c); - setColorPickerId(null); - }} - allowCustom - /> -
- )}
); }; + const renderCalendarMenu = () => { + const cal = contextMenu.data; + if (!cal) return null; + + if (isSubscriptionCalendar(cal.id)) { + const sub = getSubscriptionForCalendar(cal.id); + if (!sub || !client) return null; + return ( + + { closeContextMenu(); onEditSubscription?.(sub.id); }} + /> + { closeContextMenu(); handleRefreshSubscription(sub.id); }} + /> + + { closeContextMenu(); handleUnsubscribe(sub.id); }} + destructive + /> + {sub.lastRefreshed && ( +
+ {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })} +
+ )} +
+ ); + } + + const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID; + const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false; + const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared; + const canChangeColor = !!onColorChange; + const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false; + const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared; + const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete); + const color = cal.color || "#3b82f6"; + + return ( + + {canCreate && ( + { closeContextMenu(); onCreateEvent(cal); }} + /> + )} + {canShare && ( + { closeContextMenu(); onShareCalendar(cal); }} + /> + )} + {canChangeColor && ( + +
+ { onColorChange(cal.id, c); closeContextMenu(); }} + allowCustom + /> +
+
+ )} + {showSeparator && } + {canClear && ( + { closeContextMenu(); onClearCalendar(cal); }} + /> + )} + {canDelete && ( + { closeContextMenu(); onDeleteCalendar(cal); }} + destructive + /> + )} +
+ ); + }; + return (
{enableCalendarTasks && ( @@ -250,9 +257,22 @@ export function CalendarSidebarPanel({ )} )} -

- {t("my_calendars")} -

+
+ {onCreateCalendar ? ( + + ) : ( +

+ {t('my_calendars')} +

+ )} +
{personalCalendars.map(renderCalendarItem)}
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
))} + + {renderCalendarMenu()} ); } diff --git a/components/calendar/create-calendar-modal.tsx b/components/calendar/create-calendar-modal.tsx new file mode 100644 index 00000000..e5b675e7 --- /dev/null +++ b/components/calendar/create-calendar-modal.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { X, Loader2, Calendar as CalendarIcon } from "lucide-react"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; +import { toast } from "@/stores/toast-store"; + +interface CreateCalendarModalProps { + client: IJMAPClient; + onClose: () => void; +} + +export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) { + const t = useTranslations("calendar.management"); + const tCommon = useTranslations("common"); + const createCalendar = useCalendarStore((s) => s.createCalendar); + + const [name, setName] = useState(""); + const [color, setColor] = useState("#3b82f6"); + const [isSubmitting, setIsSubmitting] = useState(false); + const modalRef = useRef(null); + + const isValid = name.trim().length > 0; + + const handleSubmit = useCallback(async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setIsSubmitting(true); + try { + const created = await createCalendar(client, { name: trimmed, color }); + if (created) { + toast.success(t("calendar_created")); + onClose(); + } else { + toast.error(t("error_create")); + } + } catch { + toast.error(t("error_create")); + } finally { + setIsSubmitting(false); + } + }, [name, color, client, createCalendar, onClose, t]); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isSubmitting) onClose(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose, isSubmitting]); + + useEffect(() => { + const modal = modalRef.current; + if (!modal) return; + const focusableEls = modal.querySelectorAll( + 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])' + ); + const firstEl = focusableEls[0]; + const lastEl = focusableEls[focusableEls.length - 1]; + + const handler = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + if (e.shiftKey && document.activeElement === firstEl) { + e.preventDefault(); + lastEl?.focus(); + } else if (!e.shiftKey && document.activeElement === lastEl) { + e.preventDefault(); + firstEl?.focus(); + } + }; + modal.addEventListener("keydown", handler); + firstEl?.focus(); + return () => modal.removeEventListener("keydown", handler); + }, []); + + return ( +
+
!isSubmitting && onClose()} + aria-hidden="true" + /> +
+
+
+ +

{t("add_calendar")}

+
+ +
+ +
+
+ + setName(e.target.value)} + placeholder={t("name_placeholder")} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + disabled={isSubmitting} + onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }} + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 25e1edcd..fe65a867 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -35,6 +35,7 @@ interface EventModalProps { calendars: Calendar[]; defaultDate?: Date; defaultEndDate?: Date; + defaultCalendarId?: string; onSave: (data: Partial, sendSchedulingMessages?: boolean) => void | Promise; onDelete?: (id: string, sendSchedulingMessages?: boolean) => void; onDuplicate?: (data: Partial) => void; @@ -113,6 +114,7 @@ export function EventModal({ calendars, defaultDate, defaultEndDate, + defaultCalendarId, onSave, onDelete, onDuplicate, @@ -200,6 +202,7 @@ export function EventModal({ const [allDay, setAllDay] = useState(event?.showWithoutTime || false); const [calendarId, setCalendarId] = useState(() => { if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; + if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId; const defaultCal = calendars.find(c => c.isDefault); return defaultCal?.id || calendars[0]?.id || ""; }); diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index ae449b0b..5fa6c363 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -50,6 +50,7 @@ interface ContactFormProps { contact?: ContactCard | null; addressBooks?: AddressBook[]; allKeywords?: string[]; + defaultAddressBookId?: string; onSave: (data: Partial) => Promise; onCancel: () => void; } @@ -143,7 +144,7 @@ function Select({ value, onChange, children, className }: { ); } -export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) { +export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; @@ -330,8 +331,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc return ids[0]; } } + if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) { + return defaultAddressBookId; + } return ""; - }, [contact]); + }, [contact, defaultAddressBookId, addressBooks]); const [selectedBookId, setSelectedBookId] = useState(currentBookId); const initialPhotoEntry = useMemo(() => { diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 71051635..328ef384 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -27,6 +27,9 @@ interface ContactsSidebarProps { onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; onRenameAddressBook?: (addressBook: AddressBook) => void; + onShareAddressBook?: (addressBook: AddressBook) => void; + onCreateContactInBook?: (addressBook: AddressBook) => void; + onDeleteAddressBook?: (addressBook: AddressBook) => void; onRenameKeyword?: (keyword: string) => void; className?: string; } @@ -62,6 +65,9 @@ export function ContactsSidebar({ onDropContacts, onDropContactsToCategory, onRenameAddressBook, + onShareAddressBook, + onCreateContactInBook, + onDeleteAddressBook, onRenameKeyword, className, }: ContactsSidebarProps) { @@ -288,7 +294,7 @@ export function ContactsSidebar({ contactCount={contactCountByBook[book.id] || 0} onSelect={() => onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} - onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined} + onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} /> ))}
@@ -430,7 +436,7 @@ export function ContactsSidebar({ contactCount={contactCountByBook[book.id] || 0} onSelect={() => onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} - onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined} + onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} /> ))} @@ -438,24 +444,65 @@ export function ContactsSidebar({ {/* Address book context menu */} - {bookContextMenu.data && onRenameAddressBook && ( - - { - const book = bookContextMenu.data!; - closeBookContextMenu(); - onRenameAddressBook(book); - }} - /> - - )} + {bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => { + const book = bookContextMenu.data; + const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false; + const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false; + const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared; + const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false; + const showSeparator = (canCreate || canRename || canShare) && canDelete; + return ( + + {canCreate && ( + { + closeBookContextMenu(); + onCreateContactInBook(book); + }} + /> + )} + {canRename && ( + { + closeBookContextMenu(); + onRenameAddressBook(book); + }} + /> + )} + {canShare && ( + { + closeBookContextMenu(); + onShareAddressBook(book); + }} + /> + )} + {showSeparator && } + {canDelete && ( + { + closeBookContextMenu(); + onDeleteAddressBook(book); + }} + destructive + /> + )} + + ); + })()} {/* Keyword (category) context menu */} {keywordContextMenu.data && onRenameKeyword && ( diff --git a/components/settings/address-book-management-settings.tsx b/components/settings/address-book-management-settings.tsx index 635c691c..668307a8 100644 --- a/components/settings/address-book-management-settings.tsx +++ b/components/settings/address-book-management-settings.tsx @@ -2,13 +2,14 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; -import { Book, Pencil, Share2, Tag } from "lucide-react"; +import { Book, Pencil, Share2, Tag, Users } from "lucide-react"; import { useContactStore } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; import { toast } from "@/stores/toast-store"; import { SettingsSection } from "./settings-section"; import { cn } from "@/lib/utils"; -import type { AddressBook } from "@/lib/jmap/types"; +import type { AddressBook, AddressBookRights } from "@/lib/jmap/types"; +import { ShareCollectionDialog } from "./share-collection-dialog"; function AddressBookEditRow({ initial, @@ -70,9 +71,10 @@ export function AddressBookManagementSettings() { const tContacts = useTranslations("contacts"); const tSettings = useTranslations("settings.contacts"); const { client } = useAuthStore(); - const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore(); + const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore(); const [editingId, setEditingId] = useState(null); const [editingKeyword, setEditingKeyword] = useState(null); + const [sharingId, setSharingId] = useState(null); const [isLoading, setIsLoading] = useState(false); useEffect(() => { @@ -148,6 +150,16 @@ export function AddressBookManagementSettings() { )} + {!book.isShared && book.myRights?.mayShare && ( + + )} ); @@ -242,6 +254,24 @@ export function AddressBookManagementSettings() { + + {sharingId && client && (() => { + const book = addressBooks.find((b) => b.id === sharingId); + if (!book) return null; + return ( + { + await shareAddressBook(client, book, principalId, rights as AddressBookRights | null); + }} + onClose={() => setSharingId(null)} + /> + ); + })()} ); } diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 9a5151e4..e28d76d9 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -7,7 +7,9 @@ import { useAuthStore } from '@/stores/auth-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { toast } from '@/stores/toast-store'; import { SettingsSection } from './settings-section'; -import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react'; +import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react'; +import { ShareCollectionDialog } from './share-collection-dialog'; +import type { CalendarRights } from '@/lib/jmap/types'; import { cn, formatDateTime } from '@/lib/utils'; import { ICalImportModal } from '@/components/calendar/ical-import-modal'; import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal'; @@ -83,7 +85,7 @@ function CalendarColorPicker({ ); } -function CalendarEditForm({ +export function CalendarEditForm({ initial, onSave, onCancel, @@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS }; export function CalendarManagementSettings() { const t = useTranslations('calendar.management'); const { client, serverUrl, username } = useAuthStore(); - const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); + const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState>({}); const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState(null); @@ -164,6 +166,7 @@ export function CalendarManagementSettings() { const [clearingId, setClearingId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [colorPickerId, setColorPickerId] = useState(null); + const [sharingId, setSharingId] = useState(null); const [showImportModal, setShowImportModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [editingSubscription, setEditingSubscription] = useState(null); @@ -522,6 +525,16 @@ export function CalendarManagementSettings() { > + {cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && ( + + )} + + +
+

{t("description")}

+ + {sharedEntries.length === 0 && !showAdd && ( +
+ {t("no_shares")} +
+ )} + + {sharedEntries.length > 0 && ( +
    + {sharedEntries.map(([principalId, rights]) => { + const principal = allPrincipalsById.get(principalId); + const preset = kind === "calendar" + ? detectCalendarPreset(rights as CalendarRights) + : detectAddressBookPreset(rights as AddressBookRights); + return ( +
  • +
    +
    + {principal?.name || principal?.email || principalId} +
    + {principal?.description && ( +
    + {principal.description} +
    + )} +
    +
    + + +
    + +
  • + ); + })} +
+ )} + + {!showAdd && ( + + )} + + {showAdd && ( +
+ setSearch(e.target.value)} + placeholder={t("search_placeholder")} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + autoFocus + /> +
+ {loadingPrincipals && ( +
+ + {t("loading_principals")} +
+ )} + {!loadingPrincipals && filteredPrincipals.length === 0 && ( +
+ {search.trim() ? t("no_match") : t("no_principals")} +
+ )} + {!loadingPrincipals && filteredPrincipals.map((p) => ( + + ))} +
+
+ +
+
+ )} +
+ +
+ +
+ + + ); +} diff --git a/lib/__tests__/calendar-alerts.test.ts b/lib/__tests__/calendar-alerts.test.ts index 8a21cbbe..5a791262 100644 --- a/lib/__tests__/calendar-alerts.test.ts +++ b/lib/__tests__/calendar-alerts.test.ts @@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial = {}): Calendar { mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, - mayAdmin: false, + mayShare: false, mayDelete: false, }, ...overrides, diff --git a/lib/birthday-calendar.ts b/lib/birthday-calendar.ts index dab91079..efbd73ac 100644 --- a/lib/birthday-calendar.ts +++ b/lib/birthday-calendar.ts @@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, - mayAdmin: false, + mayShare: false, mayDelete: false, }, }; diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 83f36370..868196e2 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient { supportsCalendars(): boolean { return true; } supportsSieve(): boolean { return true; } supportsFiles(): boolean { return true; } + supportsPrincipals(): boolean { return false; } + async getPrincipals(): Promise { return []; } + async setCalendarShare(): Promise { /* demo: no-op */ } + async setAddressBookShare(): Promise { /* demo: no-op */ } // ── Push / state ────────────────────────────────────────────── @@ -552,6 +556,11 @@ export class DemoJMAPClient implements IJMAPClient { if (book) Object.assign(book, updates); } + async deleteAddressBook(addressBookId: string): Promise { + this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId); + this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]); + } + async getContacts(addressBookId?: string): Promise { if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]); return [...this.data.contacts]; @@ -609,7 +618,7 @@ export class DemoJMAPClient implements IJMAPClient { includeInAvailability: 'all', defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, - myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true }, + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }, ...calendar, } as Calendar; this.data.calendars.push(full); diff --git a/lib/demo/fixtures/calendars.ts b/lib/demo/fixtures/calendars.ts index ec1a9466..86affaf7 100644 --- a/lib/demo/fixtures/calendars.ts +++ b/lib/demo/fixtures/calendars.ts @@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] { defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, - myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false }, + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false }, }, { id: 'demo-calendar-work', @@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] { defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, - myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true }, + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }, }, { id: 'demo-calendar-birthdays', @@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] { defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, - myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true }, + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }, }, ]; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index a05b19b7..8f37e037 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; /** @@ -190,6 +190,7 @@ export interface IJMAPClient { getAllAddressBooks(): Promise; createAddressBook(name: string): Promise; updateAddressBook(addressBookId: string, updates: Partial, targetAccountId?: string): Promise; + deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise; getContacts(addressBookId?: string): Promise; getAllContacts(): Promise; getContact(contactId: string, accountId?: string): Promise; @@ -227,6 +228,12 @@ export interface IJMAPClient { updateCalendarTask(taskId: string, updates: Partial, targetAccountId?: string): Promise; deleteCalendarTask(taskId: string, targetAccountId?: string): Promise; + // ── Sharing (RFC 9670 Principals) ───────────────────────────── + supportsPrincipals(): boolean; + getPrincipals(targetAccountId?: string): Promise; + setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise; + setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise; + // ── Sieve / Filters ────────────────────────────────────────── getSieveAccountId(): string; getSieveCapabilities(): SieveCapabilities | null; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 9ad44504..776f71f5 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; @@ -2826,6 +2826,10 @@ export class JMAPClient implements IJMAPClient { return this.hasCapability("urn:ietf:params:jmap:sieve"); } + supportsPrincipals(): boolean { + return this.hasCapability("urn:ietf:params:jmap:principals"); + } + getSieveAccountId(): string { const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"]; return sieveAccount || this.accountId; @@ -3198,6 +3202,102 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to update address book"); } + async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); + const response = await this.request([ + ["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"], + ], this.contactUsing()); + + const result = response.methodResponses?.[0]?.[1]; + if (result?.notDestroyed?.[addressBookId]) { + const err = result.notDestroyed[addressBookId]; + throw new Error(err.description || "Failed to delete address book"); + } + } + + // ── Sharing (RFC 9670) ────────────────────────────────────────────────────── + + private principalsUsing(): string[] { + return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"]; + } + + /** + * List all principals visible to the user (RFC 9670). Stalwart returns the + * full directory regardless of `filter`, so we fetch the whole list and let + * callers filter client-side. + */ + async getPrincipals(targetAccountId?: string): Promise { + if (!this.supportsPrincipals()) return []; + const accountId = targetAccountId || this.accountId; + try { + const response = await this.request([ + ["Principal/query", { accountId }, "0"], + ["Principal/get", { + accountId, + "#ids": { resultOf: "0", name: "Principal/query", path: "/ids" }, + }, "1"], + ], this.principalsUsing()); + + const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get"); + if (!getResp) return []; + const list = (getResp[1].list || []) as Principal[]; + return list.map((p) => ({ ...p, accountId })); + } catch (error) { + console.error("Failed to fetch principals:", error); + return []; + } + } + + /** + * Add, update, or remove a principal's rights on a calendar. + * Pass `rights: null` to revoke access. + */ + async setCalendarShare( + calendarId: string, + principalId: string, + rights: CalendarRights | null, + targetAccountId?: string, + ): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); + const response = await this.request([ + ["Calendar/set", { + accountId, + update: { [calendarId]: { [`shareWith/${principalId}`]: rights } }, + }, "0"], + ], this.calendarUsing()); + + const result = response.methodResponses?.[0]?.[1]; + if (result?.notUpdated?.[calendarId]) { + const err = result.notUpdated[calendarId]; + throw new Error(err.description || "Failed to update calendar share"); + } + } + + /** + * Add, update, or remove a principal's rights on an address book. + * Pass `rights: null` to revoke access. + */ + async setAddressBookShare( + addressBookId: string, + principalId: string, + rights: AddressBookRights | null, + targetAccountId?: string, + ): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); + const response = await this.request([ + ["AddressBook/set", { + accountId, + update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } }, + }, "0"], + ], this.contactUsing()); + + const result = response.methodResponses?.[0]?.[1]; + if (result?.notUpdated?.[addressBookId]) { + const err = result.notUpdated[addressBookId]; + throw new Error(err.description || "Failed to update address book share"); + } + } + private async fetchPaginatedContacts( accountId: string, filter?: Record, diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 0c2d1714..0bbbeb7b 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -368,6 +368,7 @@ export interface AddressBook { isDefault?: boolean; isSubscribed?: boolean; myRights?: AddressBookRights; + shareWith?: Record | null; accountId?: string; accountName?: string; isShared?: boolean; @@ -376,10 +377,22 @@ export interface AddressBook { export interface AddressBookRights { mayRead: boolean; mayWrite: boolean; - mayShare: boolean; + mayShare?: boolean; mayDelete: boolean; } +// JMAP Principals (RFC 9670) +export interface Principal { + id: string; + type: 'individual' | 'group' | 'resource' | 'location' | 'other'; + name: string; + description?: string | null; + email?: string | null; + timeZone?: string | null; + capabilities?: Record; + accountId?: string; +} + export interface VacationResponse { id: string; isEnabled: boolean; @@ -442,7 +455,7 @@ export interface CalendarRights { mayWriteOwn: boolean; mayUpdatePrivate: boolean; mayRSVP: boolean; - mayAdmin: boolean; + mayShare: boolean; mayDelete: boolean; } diff --git a/locales/cs/common.json b/locales/cs/common.json index b4059a9c..cca4485c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -426,7 +426,7 @@ "event_updated": "Aktualizace #{sequence}", "event_status_tentative": "Nezávazně", "event_status_cancelled": "Zrušeno", - "expand": "Zobrazit detaily", + "expand": "Zobrazit detaily", "collapse": "Skrýt detaily" }, "send": "Odeslat", @@ -675,7 +675,7 @@ "encryption": "Šifrování", "sidebar_apps": "Aplikace postranního panelu", "notifications": "Oznámení", - "layout": "Vzhled", + "layout": "Vzhled", "reading": "Čtení", "composing": "Psaní", "content_senders": "Obsah a odesílatelé", @@ -688,7 +688,7 @@ "organization": "Organizace pošty", "apps": "Aplikace", "system": "Systém", - "appearance": "Vzhled", + "appearance": "Vzhled", "mail": "Pošta", "privacy": "Soukromí a zabezpečení", "advanced": "Pokročilé" @@ -1094,7 +1094,7 @@ "enable_error": "Nepodařilo se zapnout 2FA", "disable_error": "Nepodařilo se vypnout 2FA", "setup_instructions": "Zkopírujte tuto URL adresu do své ověřovací aplikace (Google Authenticator, Authy atd.):", - "verification_code": "Ověřovací kód", + "verification_code": "Ověřovací kód", "confirm": "Potvrdit", "disable": "Vypnout", "disable_confirm_prompt": "Pro vypnutí dvoufázového ověření zadejte heslo.", @@ -1327,7 +1327,7 @@ "contacts": { "title": "Kontakty", "description": "Import a export kontaktů", - "group_by_letter_label": "Seskupit podle prvního písmene", + "group_by_letter_label": "Seskupit podle prvního písmene", "group_by_letter_description": "Zobrazovat abecední nadpisy v seznamu kontaktů", "import_label": "Importovat kontakty", "import_description": "Importovat kontakty ze souboru vCard (.vcf)", @@ -1595,7 +1595,7 @@ "items_selected": "{count} vybraných zpráv", "edit_draft": "Upravit koncept" }, - "mailbox_context_menu": { + "mailbox_context_menu": { "mark_folder_read": "Označit složku jako přečtenou", "mark_folder_tree_read": "Označit složku a podsložky jako přečtené", "mark_all_folders_read": "Označit všechny složky jako přečtené", @@ -1613,10 +1613,10 @@ "prompt_new_subfolder": "Zadejte název nové podsložky.", "prompt_new_folder": "Zadejte název nové složky.", "prompt_rename": "Zadejte nový název této složky.", - "placeholder_folder_name": "Název složky", + "placeholder_folder_name": "Název složky", "create": "Vytvořit", - "rename_confirm": "Přejmenovat", - "toast_marked_read": "Složka označena jako přečtená", + "rename_confirm": "Přejmenovat", + "toast_marked_read": "Složka označena jako přečtená", "toast_marked_read_count": "{count, plural, one {1 zpráva označena jako přečtená} few {# zprávy označeny jako přečtené} other {# zpráv označeno jako přečtených}}", "toast_already_read": "Žádné nepřečtené zprávy", "toast_marked_all_read": "Všechny složky označeny jako přečtené", @@ -1820,7 +1820,13 @@ "renamed": "Adresář byl přejmenován", "rename_failed": "Přejmenování adresáře selhalo", "default": "Výchozí", - "manage": "Spravovat adresáře" + "manage": "Spravovat adresáře", + "share": "Sdílet adresář", + "new_contact_in_book": "Nový kontakt v tomto adresáři", + "delete": "Smazat adresář", + "confirm_delete": "Smazat „{name}\"? Všechny kontakty v tomto adresáři budou odstraněny.", + "deleted": "Adresář smazán", + "delete_failed": "Adresář se nepodařilo smazat" }, "detail": { "emails": "E-mailové adresy", @@ -1995,7 +2001,7 @@ "email_error_inline": "Neplatný formát e-mailové adresy", "save_failed": "Uložení kontaktu selhalo", "delete": "Odstranit", - "upload_photo": "Nahrát fotku", + "upload_photo": "Nahrát fotku", "remove_photo": "Odebrat fotku", "photo_hint": "JPG nebo PNG, max. 10 MB. Velikost se upraví.", "photo_too_large": "Obrázek je moc velký (max. 10 MB)", @@ -2091,7 +2097,7 @@ "has_email": "Má e-mail", "has_phone": "Má telefon", "has_photo": "Má fotku" - } + } }, "calendar": { "title": "Kalendář", @@ -2345,7 +2351,9 @@ "error_delete": "Odstranění kalendáře selhalo", "caldav_url": "URL CalDAV", "copy_url": "Kopírovat URL CalDAV", - "url_copied": "URL CalDAV zkopírováno do schránky" + "url_copied": "URL CalDAV zkopírováno do schránky", + "share": "Sdílet kalendář", + "new_event_in_calendar": "Nová událost v tomto kalendáři" }, "subscription": { "title": "Odběr iCal", @@ -2711,5 +2719,28 @@ }, "unified_mailbox": { "search_unavailable": "Vyhledávání není ve sjednoceném zobrazení k dispozici" + }, + "sharing": { + "title": "Sdílet „{name}\"", + "description": "Udělte přístup dalším uživatelům nebo skupinám na tomto serveru. Změny se projeví okamžitě.", + "no_shares": "Zatím nikomu nesdíleno.", + "add_person": "Přidat osobu nebo skupinu", + "search_placeholder": "Hledat podle jména nebo e-mailu…", + "loading_principals": "Načítání uživatelů…", + "no_principals": "Nenalezeni žádní další uživatelé ani skupiny.", + "no_match": "Žádné výsledky.", + "remove": "Odebrat přístup", + "group": "Skupina", + "share_added": "Přístup udělen", + "share_updated": "Přístup aktualizován", + "share_removed": "Přístup odebrán", + "share_failed": "Aktualizace sdílení selhala", + "preset": { + "freeBusy": "Pouze volno/zaneprázdněno", + "read": "Pouze čtení", + "readWrite": "Čtení a zápis", + "manager": "Správce", + "custom": "Vlastní" + } } } diff --git a/locales/de/common.json b/locales/de/common.json index 29d1b622..97228c21 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1815,7 +1815,13 @@ "renamed": "Adressbuch umbenannt", "rename_failed": "Adressbuch konnte nicht umbenannt werden", "default": "Standard", - "manage": "Adressbücher verwalten" + "manage": "Adressbücher verwalten", + "share": "Adressbuch freigeben", + "new_contact_in_book": "Neuer Kontakt in diesem Adressbuch", + "delete": "Adressbuch löschen", + "confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.", + "deleted": "Adressbuch gelöscht", + "delete_failed": "Adressbuch konnte nicht gelöscht werden" }, "detail": { "emails": "E-Mail-Adressen", @@ -2340,7 +2346,9 @@ "confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.", "clear_events": "Ereignisse löschen", "events_cleared": "{count} Ereignisse gelöscht", - "error_clear": "Kalenderereignisse konnten nicht gelöscht werden" + "error_clear": "Kalenderereignisse konnten nicht gelöscht werden", + "share": "Kalender freigeben", + "new_event_in_calendar": "Neuer Termin in diesem Kalender" }, "subscription": { "title": "iCal-Abonnement", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar" + }, + "sharing": { + "title": "„{name}\" freigeben", + "description": "Anderen Benutzern oder Gruppen auf diesem Server Zugriff gewähren. Änderungen werden sofort wirksam.", + "no_shares": "Noch nicht freigegeben.", + "add_person": "Person oder Gruppe hinzufügen", + "search_placeholder": "Nach Name oder E-Mail suchen…", + "loading_principals": "Benutzer werden geladen…", + "no_principals": "Keine weiteren Benutzer oder Gruppen gefunden.", + "no_match": "Keine Treffer.", + "remove": "Zugriff entfernen", + "group": "Gruppe", + "share_added": "Zugriff erteilt", + "share_updated": "Zugriff aktualisiert", + "share_removed": "Zugriff entfernt", + "share_failed": "Freigabe konnte nicht aktualisiert werden", + "preset": { + "freeBusy": "Nur Frei/Belegt", + "read": "Nur lesen", + "readWrite": "Lesen & schreiben", + "manager": "Verwalten", + "custom": "Benutzerdefiniert" + } } } diff --git a/locales/en/common.json b/locales/en/common.json index 23618207..ff1c2a05 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1819,7 +1819,13 @@ "renamed": "Address book renamed", "rename_failed": "Failed to rename address book", "default": "Default", - "manage": "Manage address books" + "manage": "Manage address books", + "share": "Share address book", + "new_contact_in_book": "New contact in this address book", + "delete": "Delete address book", + "confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.", + "deleted": "Address book deleted", + "delete_failed": "Failed to delete address book" }, "detail": { "emails": "Email Addresses", @@ -2344,7 +2350,9 @@ "error_delete": "Failed to delete calendar", "caldav_url": "CalDAV URL", "copy_url": "Copy CalDAV URL", - "url_copied": "CalDAV URL copied to clipboard" + "url_copied": "CalDAV URL copied to clipboard", + "share": "Share calendar", + "new_event_in_calendar": "New event in this calendar" }, "subscription": { "title": "iCal Subscription", @@ -2426,6 +2434,29 @@ "overdue": "Overdue" } }, + "sharing": { + "title": "Share \"{name}\"", + "description": "Grant access to other users or groups on this server. Changes take effect immediately.", + "no_shares": "Not shared with anyone yet.", + "add_person": "Add person or group", + "search_placeholder": "Search by name or email…", + "loading_principals": "Loading users…", + "no_principals": "No other users or groups found.", + "no_match": "No matches.", + "remove": "Remove access", + "group": "Group", + "share_added": "Access granted", + "share_updated": "Access updated", + "share_removed": "Access removed", + "share_failed": "Failed to update sharing", + "preset": { + "freeBusy": "Free/busy only", + "read": "Read only", + "readWrite": "Read & write", + "manager": "Manager", + "custom": "Custom" + } + }, "advanced_search": { "title": "Advanced Search", "from": "From", diff --git a/locales/es/common.json b/locales/es/common.json index 77d94863..50d099d9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1815,7 +1815,13 @@ "renamed": "Libreta de direcciones renombrada", "rename_failed": "Error al renombrar la libreta de direcciones", "default": "Predeterminada", - "manage": "Administrar libretas de direcciones" + "manage": "Administrar libretas de direcciones", + "share": "Compartir libreta de direcciones", + "new_contact_in_book": "Nuevo contacto en esta libreta", + "delete": "Eliminar libreta de direcciones", + "confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.", + "deleted": "Libreta de direcciones eliminada", + "delete_failed": "No se pudo eliminar la libreta de direcciones" }, "detail": { "emails": "Direcciones de correo", @@ -2340,7 +2346,9 @@ "confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.", "clear_events": "Borrar eventos", "events_cleared": "{count} eventos borrados", - "error_clear": "No se pudieron borrar los eventos del calendario" + "error_clear": "No se pudieron borrar los eventos del calendario", + "share": "Compartir calendario", + "new_event_in_calendar": "Nuevo evento en este calendario" }, "subscription": { "title": "Suscripción iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "La búsqueda no está disponible en la vista unificada" + }, + "sharing": { + "title": "Compartir «{name}»", + "description": "Concede acceso a otros usuarios o grupos de este servidor. Los cambios surten efecto inmediatamente.", + "no_shares": "Aún no se ha compartido con nadie.", + "add_person": "Añadir persona o grupo", + "search_placeholder": "Buscar por nombre o correo…", + "loading_principals": "Cargando usuarios…", + "no_principals": "No se han encontrado otros usuarios ni grupos.", + "no_match": "Sin resultados.", + "remove": "Quitar acceso", + "group": "Grupo", + "share_added": "Acceso concedido", + "share_updated": "Acceso actualizado", + "share_removed": "Acceso retirado", + "share_failed": "No se pudo actualizar el uso compartido", + "preset": { + "freeBusy": "Solo disponibilidad", + "read": "Solo lectura", + "readWrite": "Lectura y escritura", + "manager": "Administrador", + "custom": "Personalizado" + } } } diff --git a/locales/fr/common.json b/locales/fr/common.json index 34fc8281..b37618cd 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1815,7 +1815,13 @@ "renamed": "Carnet d'adresses renommé", "rename_failed": "Échec du renommage du carnet d'adresses", "default": "Par défaut", - "manage": "Gérer les carnets d'adresses" + "manage": "Gérer les carnets d'adresses", + "share": "Partager le carnet d'adresses", + "new_contact_in_book": "Nouveau contact dans ce carnet d'adresses", + "delete": "Supprimer le carnet d'adresses", + "confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.", + "deleted": "Carnet d'adresses supprimé", + "delete_failed": "Échec de la suppression du carnet d'adresses" }, "detail": { "emails": "Adresses e-mail", @@ -2340,7 +2346,9 @@ "confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.", "clear_events": "Supprimer les événements", "events_cleared": "{count} événements supprimés", - "error_clear": "Impossible de supprimer les événements du calendrier" + "error_clear": "Impossible de supprimer les événements du calendrier", + "share": "Partager le calendrier", + "new_event_in_calendar": "Nouvel événement dans ce calendrier" }, "subscription": { "title": "Abonnement iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "La recherche n'est pas disponible dans la vue unifiée" + }, + "sharing": { + "title": "Partager « {name} »", + "description": "Accordez l'accès à d'autres utilisateurs ou groupes de ce serveur. Les modifications sont immédiates.", + "no_shares": "Pas encore partagé.", + "add_person": "Ajouter une personne ou un groupe", + "search_placeholder": "Rechercher par nom ou e-mail…", + "loading_principals": "Chargement des utilisateurs…", + "no_principals": "Aucun autre utilisateur ou groupe trouvé.", + "no_match": "Aucun résultat.", + "remove": "Révoquer l'accès", + "group": "Groupe", + "share_added": "Accès accordé", + "share_updated": "Accès mis à jour", + "share_removed": "Accès révoqué", + "share_failed": "Échec de la mise à jour du partage", + "preset": { + "freeBusy": "Disponibilité uniquement", + "read": "Lecture seule", + "readWrite": "Lecture & écriture", + "manager": "Gestionnaire", + "custom": "Personnalisé" + } } } diff --git a/locales/it/common.json b/locales/it/common.json index 85c99029..050fd9f4 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1815,7 +1815,13 @@ "renamed": "Rubrica rinominata", "rename_failed": "Impossibile rinominare la rubrica", "default": "Predefinita", - "manage": "Gestisci rubriche" + "manage": "Gestisci rubriche", + "share": "Condividi rubrica", + "new_contact_in_book": "Nuovo contatto in questa rubrica", + "delete": "Elimina rubrica", + "confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.", + "deleted": "Rubrica eliminata", + "delete_failed": "Impossibile eliminare la rubrica" }, "detail": { "emails": "Indirizzi email", @@ -2340,7 +2346,9 @@ "confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.", "clear_events": "Cancella eventi", "events_cleared": "{count} eventi cancellati", - "error_clear": "Impossibile cancellare gli eventi del calendario" + "error_clear": "Impossibile cancellare gli eventi del calendario", + "share": "Condividi calendario", + "new_event_in_calendar": "Nuovo evento in questo calendario" }, "subscription": { "title": "Abbonamento iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "La ricerca non è disponibile nella vista unificata" + }, + "sharing": { + "title": "Condividi \"{name}\"", + "description": "Concedi l'accesso ad altri utenti o gruppi su questo server. Le modifiche hanno effetto immediato.", + "no_shares": "Non ancora condiviso.", + "add_person": "Aggiungi persona o gruppo", + "search_placeholder": "Cerca per nome o email…", + "loading_principals": "Caricamento utenti…", + "no_principals": "Nessun altro utente o gruppo trovato.", + "no_match": "Nessun risultato.", + "remove": "Rimuovi accesso", + "group": "Gruppo", + "share_added": "Accesso concesso", + "share_updated": "Accesso aggiornato", + "share_removed": "Accesso rimosso", + "share_failed": "Impossibile aggiornare la condivisione", + "preset": { + "freeBusy": "Solo libero/occupato", + "read": "Sola lettura", + "readWrite": "Lettura e scrittura", + "manager": "Gestore", + "custom": "Personalizzato" + } } } diff --git a/locales/ja/common.json b/locales/ja/common.json index e7592ab0..00bc89ff 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1815,7 +1815,13 @@ "renamed": "アドレス帳の名前を変更しました", "rename_failed": "アドレス帳の名前変更に失敗しました", "default": "デフォルト", - "manage": "アドレス帳を管理" + "manage": "アドレス帳を管理", + "share": "アドレス帳を共有", + "new_contact_in_book": "このアドレス帳に新規連絡先", + "delete": "アドレス帳を削除", + "confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。", + "deleted": "アドレス帳を削除しました", + "delete_failed": "アドレス帳の削除に失敗しました" }, "detail": { "emails": "メールアドレス", @@ -2340,7 +2346,9 @@ "confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。", "clear_events": "イベントを削除", "events_cleared": "{count}件のイベントを削除しました", - "error_clear": "カレンダーイベントの削除に失敗しました" + "error_clear": "カレンダーイベントの削除に失敗しました", + "share": "カレンダーを共有", + "new_event_in_calendar": "このカレンダーに新規イベント" }, "subscription": { "title": "iCal購読", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "統合ビューでは検索を利用できません" + }, + "sharing": { + "title": "「{name}」を共有", + "description": "このサーバー上の他のユーザーまたはグループにアクセス権を付与します。変更はすぐに反映されます。", + "no_shares": "まだ誰にも共有されていません。", + "add_person": "ユーザーまたはグループを追加", + "search_placeholder": "名前またはメールで検索…", + "loading_principals": "ユーザーを読み込み中…", + "no_principals": "他のユーザーまたはグループは見つかりません。", + "no_match": "一致する項目がありません。", + "remove": "アクセス権を削除", + "group": "グループ", + "share_added": "アクセス権を付与しました", + "share_updated": "アクセス権を更新しました", + "share_removed": "アクセス権を削除しました", + "share_failed": "共有の更新に失敗しました", + "preset": { + "freeBusy": "空き時間情報のみ", + "read": "読み取り専用", + "readWrite": "読み取り・書き込み", + "manager": "管理者", + "custom": "カスタム" + } } } diff --git a/locales/ko/common.json b/locales/ko/common.json index 2aed2cc0..0ae89054 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1815,7 +1815,13 @@ "renamed": "주소록 이름이 변경되었습니다", "rename_failed": "주소록 이름 변경 실패", "default": "기본", - "manage": "주소록 관리" + "manage": "주소록 관리", + "share": "주소록 공유", + "new_contact_in_book": "이 주소록에 새 연락처", + "delete": "주소록 삭제", + "confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.", + "deleted": "주소록이 삭제되었습니다", + "delete_failed": "주소록 삭제에 실패했습니다" }, "detail": { "emails": "이메일", @@ -2340,7 +2346,9 @@ "error_delete": "캘린더를 삭제하지 못했어요", "caldav_url": "CalDAV URL", "copy_url": "CalDAV URL 복사", - "url_copied": "CalDAV URL이 클립보드에 복사되었어요" + "url_copied": "CalDAV URL이 클립보드에 복사되었어요", + "share": "캘린더 공유", + "new_event_in_calendar": "이 캘린더에 새 일정" }, "subscription": { "title": "iCal 구독", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다" + }, + "sharing": { + "title": "\"{name}\" 공유", + "description": "이 서버의 다른 사용자나 그룹에 액세스 권한을 부여합니다. 변경 사항은 즉시 적용됩니다.", + "no_shares": "아직 공유되지 않았습니다.", + "add_person": "사용자 또는 그룹 추가", + "search_placeholder": "이름 또는 이메일로 검색…", + "loading_principals": "사용자 불러오는 중…", + "no_principals": "다른 사용자나 그룹을 찾을 수 없습니다.", + "no_match": "일치하는 항목이 없습니다.", + "remove": "액세스 권한 제거", + "group": "그룹", + "share_added": "액세스 권한이 부여되었습니다", + "share_updated": "액세스 권한이 업데이트되었습니다", + "share_removed": "액세스 권한이 제거되었습니다", + "share_failed": "공유 업데이트에 실패했습니다", + "preset": { + "freeBusy": "한가함/바쁨만", + "read": "읽기 전용", + "readWrite": "읽기 및 쓰기", + "manager": "관리자", + "custom": "사용자 지정" + } } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 44da8b8d..a8830bb1 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1811,7 +1811,13 @@ "renamed": "Adrešu grāmata pārdēvēta", "rename_failed": "Neizdevās pārdēvēt adrešu grāmatu", "default": "Noklusējuma", - "manage": "Pārvaldīt adrešu grāmatas" + "manage": "Pārvaldīt adrešu grāmatas", + "share": "Kopīgot adrešu grāmatu", + "new_contact_in_book": "Jauns kontakts šajā adrešu grāmatā", + "delete": "Dzēst adrešu grāmatu", + "confirm_delete": "Dzēst \"{name}\"? Visi kontakti šajā adrešu grāmatā tiks noņemti.", + "deleted": "Adrešu grāmata dzēsta", + "delete_failed": "Neizdevās dzēst adrešu grāmatu" }, "detail": { "emails": "E-pasta adreses", @@ -2339,7 +2345,9 @@ "error_delete": "Neizdevās izdzēst kalendāru", "caldav_url": "CalDAV URL", "copy_url": "Kopēt CalDAV URL", - "url_copied": "CalDAV URL nokopēts starpliktuvē" + "url_copied": "CalDAV URL nokopēts starpliktuvē", + "share": "Kopīgot kalendāru", + "new_event_in_calendar": "Jauns notikums šajā kalendārā" }, "subscription": { "title": "iCal abonements", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Meklēšana nav pieejama apvienotajā skatā" + }, + "sharing": { + "title": "Kopīgot \"{name}\"", + "description": "Piešķiriet piekļuvi citiem lietotājiem vai grupām šajā serverī. Izmaiņas stājas spēkā nekavējoties.", + "no_shares": "Vēl nav kopīgots.", + "add_person": "Pievienot personu vai grupu", + "search_placeholder": "Meklēt pēc vārda vai e-pasta…", + "loading_principals": "Ielādē lietotājus…", + "no_principals": "Citi lietotāji vai grupas nav atrastas.", + "no_match": "Nav atbilstību.", + "remove": "Noņemt piekļuvi", + "group": "Grupa", + "share_added": "Piekļuve piešķirta", + "share_updated": "Piekļuve atjaunināta", + "share_removed": "Piekļuve noņemta", + "share_failed": "Neizdevās atjaunināt kopīgošanu", + "preset": { + "freeBusy": "Tikai brīvs/aizņemts", + "read": "Tikai lasīšana", + "readWrite": "Lasīšana un rakstīšana", + "manager": "Pārvaldnieks", + "custom": "Pielāgots" + } } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 44dd31af..42b6e9ce 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1815,7 +1815,13 @@ "renamed": "Adresboek hernoemd", "rename_failed": "Adresboek hernoemen mislukt", "default": "Standaard", - "manage": "Adresboeken beheren" + "manage": "Adresboeken beheren", + "share": "Adresboek delen", + "new_contact_in_book": "Nieuw contact in dit adresboek", + "delete": "Adresboek verwijderen", + "confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.", + "deleted": "Adresboek verwijderd", + "delete_failed": "Adresboek kon niet worden verwijderd" }, "detail": { "emails": "E-mailadressen", @@ -2340,7 +2346,9 @@ "confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.", "clear_events": "Afspraken verwijderen", "events_cleared": "{count} afspraken verwijderd", - "error_clear": "Kan agendagebeurtenissen niet verwijderen" + "error_clear": "Kan agendagebeurtenissen niet verwijderen", + "share": "Agenda delen", + "new_event_in_calendar": "Nieuwe afspraak in deze agenda" }, "subscription": { "title": "iCal-abonnement", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave" + }, + "sharing": { + "title": "\"{name}\" delen", + "description": "Geef andere gebruikers of groepen op deze server toegang. Wijzigingen zijn direct van kracht.", + "no_shares": "Nog niet gedeeld.", + "add_person": "Persoon of groep toevoegen", + "search_placeholder": "Zoeken op naam of e-mail…", + "loading_principals": "Gebruikers laden…", + "no_principals": "Geen andere gebruikers of groepen gevonden.", + "no_match": "Geen overeenkomsten.", + "remove": "Toegang intrekken", + "group": "Groep", + "share_added": "Toegang verleend", + "share_updated": "Toegang bijgewerkt", + "share_removed": "Toegang ingetrokken", + "share_failed": "Delen kon niet worden bijgewerkt", + "preset": { + "freeBusy": "Alleen vrij/bezet", + "read": "Alleen lezen", + "readWrite": "Lezen en schrijven", + "manager": "Beheerder", + "custom": "Aangepast" + } } } diff --git a/locales/pl/common.json b/locales/pl/common.json index f010cbe6..e6778fcf 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1815,7 +1815,13 @@ "renamed": "Zmieniono nazwę książki adresowej", "rename_failed": "Nie udało się zmienić nazwy książki adresowej", "default": "Domyślna", - "manage": "Zarządzaj książkami adresowymi" + "manage": "Zarządzaj książkami adresowymi", + "share": "Udostępnij książkę adresową", + "new_contact_in_book": "Nowy kontakt w tej książce adresowej", + "delete": "Usuń książkę adresową", + "confirm_delete": "Usunąć „{name}\"? Wszystkie kontakty w tej książce adresowej zostaną usunięte.", + "deleted": "Książka adresowa usunięta", + "delete_failed": "Nie udało się usunąć książki adresowej" }, "detail": { "emails": "Adresy e-mail", @@ -2340,7 +2346,9 @@ "error_delete": "Nie udało się usunąć kalendarza", "caldav_url": "Adres URL CalDAV", "copy_url": "Kopiuj adres URL CalDAV", - "url_copied": "Adres URL CalDAV skopiowano do schowka" + "url_copied": "Adres URL CalDAV skopiowano do schowka", + "share": "Udostępnij kalendarz", + "new_event_in_calendar": "Nowe wydarzenie w tym kalendarzu" }, "subscription": { "title": "Subskrypcja iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym" + }, + "sharing": { + "title": "Udostępnij „{name}\"", + "description": "Udziel dostępu innym użytkownikom lub grupom na tym serwerze. Zmiany są natychmiastowe.", + "no_shares": "Jeszcze nie udostępniono.", + "add_person": "Dodaj osobę lub grupę", + "search_placeholder": "Szukaj po imieniu lub e-mailu…", + "loading_principals": "Ładowanie użytkowników…", + "no_principals": "Nie znaleziono innych użytkowników ani grup.", + "no_match": "Brak wyników.", + "remove": "Usuń dostęp", + "group": "Grupa", + "share_added": "Dostęp przyznany", + "share_updated": "Dostęp zaktualizowany", + "share_removed": "Dostęp usunięty", + "share_failed": "Nie udało się zaktualizować udostępniania", + "preset": { + "freeBusy": "Tylko dostępność", + "read": "Tylko do odczytu", + "readWrite": "Odczyt i zapis", + "manager": "Menedżer", + "custom": "Niestandardowe" + } } } diff --git a/locales/pt/common.json b/locales/pt/common.json index f903909e..4ffc6c03 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1815,7 +1815,13 @@ "renamed": "Catálogo de endereços renomeado", "rename_failed": "Falha ao renomear o catálogo de endereços", "default": "Padrão", - "manage": "Gerenciar catálogos de endereços" + "manage": "Gerenciar catálogos de endereços", + "share": "Compartilhar lista de contatos", + "new_contact_in_book": "Novo contato nesta lista", + "delete": "Excluir lista de contatos", + "confirm_delete": "Excluir \"{name}\"? Todos os contatos desta lista serão removidos.", + "deleted": "Lista de contatos excluída", + "delete_failed": "Falha ao excluir a lista de contatos" }, "detail": { "emails": "Endereços de e-mail", @@ -2340,7 +2346,9 @@ "confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.", "clear_events": "Limpar eventos", "events_cleared": "{count} eventos removidos", - "error_clear": "Falha ao limpar os eventos do calendário" + "error_clear": "Falha ao limpar os eventos do calendário", + "share": "Compartilhar calendário", + "new_event_in_calendar": "Novo evento neste calendário" }, "subscription": { "title": "Assinatura iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "A pesquisa não está disponível na vista unificada" + }, + "sharing": { + "title": "Compartilhar \"{name}\"", + "description": "Conceda acesso a outros usuários ou grupos neste servidor. As alterações têm efeito imediato.", + "no_shares": "Ainda não compartilhado.", + "add_person": "Adicionar pessoa ou grupo", + "search_placeholder": "Buscar por nome ou e-mail…", + "loading_principals": "Carregando usuários…", + "no_principals": "Nenhum outro usuário ou grupo encontrado.", + "no_match": "Sem resultados.", + "remove": "Remover acesso", + "group": "Grupo", + "share_added": "Acesso concedido", + "share_updated": "Acesso atualizado", + "share_removed": "Acesso removido", + "share_failed": "Falha ao atualizar o compartilhamento", + "preset": { + "freeBusy": "Apenas disponibilidade", + "read": "Somente leitura", + "readWrite": "Leitura e escrita", + "manager": "Gerente", + "custom": "Personalizado" + } } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 0969ec71..d6fcebe0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1815,7 +1815,13 @@ "renamed": "Адресная книга переименована", "rename_failed": "Не удалось переименовать адресную книгу", "default": "По умолчанию", - "manage": "Управление адресными книгами" + "manage": "Управление адресными книгами", + "share": "Поделиться адресной книгой", + "new_contact_in_book": "Новый контакт в этой адресной книге", + "delete": "Удалить адресную книгу", + "confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.", + "deleted": "Адресная книга удалена", + "delete_failed": "Не удалось удалить адресную книгу" }, "detail": { "emails": "Адреса электронной почты", @@ -2340,7 +2346,9 @@ "error_delete": "Не удалось удалить календарь", "caldav_url": "URL CalDAV", "copy_url": "Скопировать CalDAV URL", - "url_copied": "CalDAV URL скопирован в буфер обмена" + "url_copied": "CalDAV URL скопирован в буфер обмена", + "share": "Поделиться календарём", + "new_event_in_calendar": "Новое событие в этом календаре" }, "subscription": { "title": "Подписка iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Поиск недоступен в объединённом представлении" + }, + "sharing": { + "title": "Поделиться «{name}»", + "description": "Предоставьте доступ другим пользователям или группам на этом сервере. Изменения вступают в силу немедленно.", + "no_shares": "Пока никому не предоставлен доступ.", + "add_person": "Добавить пользователя или группу", + "search_placeholder": "Искать по имени или email…", + "loading_principals": "Загрузка пользователей…", + "no_principals": "Других пользователей или групп не найдено.", + "no_match": "Нет совпадений.", + "remove": "Отозвать доступ", + "group": "Группа", + "share_added": "Доступ предоставлен", + "share_updated": "Доступ обновлён", + "share_removed": "Доступ отозван", + "share_failed": "Не удалось обновить общий доступ", + "preset": { + "freeBusy": "Только занятость", + "read": "Только чтение", + "readWrite": "Чтение и запись", + "manager": "Управляющий", + "custom": "Пользовательский" + } } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 3ea4da5c..e026fcb2 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1815,7 +1815,13 @@ "renamed": "Адресну книгу перейменовано", "rename_failed": "Не вдалося перейменувати адресну книгу", "default": "За замовчуванням", - "manage": "Керуйте адресними книгами" + "manage": "Керуйте адресними книгами", + "share": "Поділитися адресною книгою", + "new_contact_in_book": "Новий контакт у цій адресній книзі", + "delete": "Видалити адресну книгу", + "confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.", + "deleted": "Адресну книгу видалено", + "delete_failed": "Не вдалося видалити адресну книгу" }, "detail": { "emails": "Адреси електронної пошти", @@ -2340,7 +2346,9 @@ "error_delete": "Не вдалося видалити календар", "caldav_url": "URL-адреса CalDAV", "copy_url": "Скопіюйте URL-адресу CalDAV", - "url_copied": "URL-адресу CalDAV скопійовано в буфер обміну" + "url_copied": "URL-адресу CalDAV скопійовано в буфер обміну", + "share": "Поділитися календарем", + "new_event_in_calendar": "Нова подія в цьому календарі" }, "subscription": { "title": "Підписка iCal", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "Пошук недоступний в об'єднаному перегляді" + }, + "sharing": { + "title": "Поділитися «{name}»", + "description": "Надайте доступ іншим користувачам або групам на цьому сервері. Зміни набувають чинності негайно.", + "no_shares": "Поки що ні з ким не поділено.", + "add_person": "Додати людину або групу", + "search_placeholder": "Шукати за іменем або email…", + "loading_principals": "Завантаження користувачів…", + "no_principals": "Інших користувачів або груп не знайдено.", + "no_match": "Збігів немає.", + "remove": "Видалити доступ", + "group": "Група", + "share_added": "Доступ надано", + "share_updated": "Доступ оновлено", + "share_removed": "Доступ видалено", + "share_failed": "Не вдалося оновити спільний доступ", + "preset": { + "freeBusy": "Лише зайнятість", + "read": "Лише читання", + "readWrite": "Читання та запис", + "manager": "Керівник", + "custom": "Власне" + } } } diff --git a/locales/zh/common.json b/locales/zh/common.json index f4bb33ea..ce2b639f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1815,7 +1815,13 @@ "renamed": "地址簿已重命名", "rename_failed": "重命名地址簿失败", "default": "默认", - "manage": "管理地址簿" + "manage": "管理地址簿", + "share": "共享通讯录", + "new_contact_in_book": "在此通讯录中新建联系人", + "delete": "删除通讯录", + "confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。", + "deleted": "通讯录已删除", + "delete_failed": "删除通讯录失败" }, "detail": { "emails": "邮箱地址", @@ -2340,7 +2346,9 @@ "error_delete": "删除日历失败", "caldav_url": "CalDAV URL", "copy_url": "复制 CalDAV URL", - "url_copied": "CalDAV URL 已复制到剪贴板" + "url_copied": "CalDAV URL 已复制到剪贴板", + "share": "共享日历", + "new_event_in_calendar": "在此日历中新建事件" }, "subscription": { "title": "iCal 订阅", @@ -2706,5 +2714,28 @@ }, "unified_mailbox": { "search_unavailable": "统一视图中无法使用搜索" + }, + "sharing": { + "title": "共享「{name}」", + "description": "向此服务器上的其他用户或群组授予访问权限。更改会立即生效。", + "no_shares": "尚未共享。", + "add_person": "添加用户或群组", + "search_placeholder": "按姓名或邮箱搜索…", + "loading_principals": "正在加载用户…", + "no_principals": "未找到其他用户或群组。", + "no_match": "无匹配项。", + "remove": "取消访问", + "group": "群组", + "share_added": "已授予访问权限", + "share_updated": "已更新访问权限", + "share_removed": "已取消访问权限", + "share_failed": "更新共享失败", + "preset": { + "freeBusy": "仅显示忙/闲", + "read": "只读", + "readWrite": "读写", + "manager": "管理员", + "custom": "自定义" + } } } diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 7d7125bb..75f199b7 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; +import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types'; import { debug } from '@/lib/debug'; import { normalizeAllDayDuration } from '@/lib/calendar-utils'; import { parseDuration } from '@/components/calendar/event-card'; @@ -125,6 +125,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; + shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise; createCalendar: (client: IJMAPClient, calendar: Partial) => Promise; removeCalendar: (client: IJMAPClient, calendarId: string) => Promise; clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise; @@ -653,6 +654,29 @@ export const useCalendarStore = create()( } }, + shareCalendar: async (client, calendarId, principalId, rights) => { + set({ error: null }); + try { + const cal = get().calendars.find(c => c.id === calendarId); + const realId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; + await client.setCalendarShare(realId, principalId, rights, targetAccountId); + set((state) => ({ + calendars: state.calendars.map(c => { + if (c.id !== calendarId) return c; + const next = { ...(c.shareWith ?? {}) }; + if (rights === null) delete next[principalId]; + else next[principalId] = rights; + return { ...c, shareWith: next }; + }), + })); + } catch (error) { + debug.error('Failed to share calendar:', error); + set({ error: 'Failed to share calendar' }); + throw error; + } + }, + createCalendar: async (client, calendar) => { set({ error: null }); try { diff --git a/stores/contact-store.ts b/stores/contact-store.ts index b388d2a9..a376ed7a 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types'; +import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { generateUUID } from '@/lib/utils'; import { debug } from '@/lib/debug'; @@ -101,6 +101,8 @@ interface ContactStore { bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise; moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise; renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise; + removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise; + shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise; renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise; importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise; @@ -655,6 +657,45 @@ export const useContactStore = create()( } }, + removeAddressBook: async (client, addressBook) => { + set({ error: null }); + try { + const originalId = addressBook.originalId || addressBook.id; + const accountId = addressBook.isShared ? addressBook.accountId : undefined; + await client.deleteAddressBook(originalId, accountId); + set((state) => ({ + addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id), + contacts: state.contacts.filter(c => !c.addressBookIds?.[addressBook.id]), + })); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to delete address book'; + set({ error: msg }); + throw error; + } + }, + + shareAddressBook: async (client, addressBook, principalId, rights) => { + set({ error: null }); + try { + const originalId = addressBook.originalId || addressBook.id; + const accountId = addressBook.isShared ? addressBook.accountId : undefined; + await client.setAddressBookShare(originalId, principalId, rights, accountId); + set((state) => ({ + addressBooks: state.addressBooks.map(b => { + if (b.id !== addressBook.id) return b; + const next = { ...(b.shareWith ?? {}) }; + if (rights === null) delete next[principalId]; + else next[principalId] = rights; + return { ...b, shareWith: next }; + }), + })); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to share address book'; + set({ error: msg }); + throw error; + } + }, + renameKeyword: async (client, oldKeyword, newKeyword) => { set({ error: null }); const oldKw = oldKeyword.trim();