feat: add JMAP sharing for calendars and address books

This commit is contained in:
Linus Rath
2026-04-26 20:10:04 +02:00
parent 511740bb6d
commit 3e1de10213
34 changed files with 1605 additions and 192 deletions
+79 -4
View File
@@ -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<string | null>(null);
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
const tMgmt = useTranslations("calendar.management");
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -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)}
/>
<ConfirmDialog {...confirmDialogProps} />
{showCreateCalendar && client && (
<CreateCalendarModal
client={client}
onClose={() => setShowCreateCalendar(false)}
/>
)}
{sharingCalendarId && client && (() => {
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
if (!cal) return null;
return (
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
}}
onClose={() => setSharingCalendarId(null)}
/>
);
})()}
</div>
);
}
+46 -2
View File
@@ -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<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(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 <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
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)}
/>
</div>
@@ -838,6 +865,23 @@ export default function ContactsPage() {
</div>
</div>
)}
{sharingAddressBookId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingAddressBookId(null)}
/>
);
})()}
</div>
);
}
+127 -105
View File
@@ -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<string | null>(null);
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(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 (
<div key={cal.id} className="relative">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => {
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Subscription context menu on right-click */}
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => {
setContextMenuCalId(null);
onEditSubscription?.(sub.id);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{tSub('edit')}
</button>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
ref={colorPickerRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
<CalendarColorPicker
value={color}
onChange={(c) => {
onColorChange(cal.id, c);
setColorPickerId(null);
}}
allowCustom
/>
</div>
)}
</div>
);
};
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 (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem
icon={Pencil}
label={tSub('edit')}
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
/>
<ContextMenuItem
icon={RefreshCw}
label={tSub('refresh')}
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={tSub('unsubscribe')}
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
destructive
/>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</ContextMenu>
);
}
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 (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
{canCreate && (
<ContextMenuItem
icon={Plus}
label={tMgmt('new_event_in_calendar')}
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
/>
)}
{canShare && (
<ContextMenuItem
icon={Users}
label={tMgmt('share')}
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
)}
{canChangeColor && (
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
<div className="px-2 py-1.5 w-[200px]">
<CalendarColorPicker
value={color}
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
allowCustom
/>
</div>
</ContextMenuSubMenu>
)}
{showSeparator && <ContextMenuSeparator />}
{canClear && (
<ContextMenuItem
icon={Eraser}
label={tMgmt('clear_events')}
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
/>
)}
{canDelete && (
<ContextMenuItem
icon={Trash2}
label={tMgmt('delete')}
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
destructive
/>
)}
</ContextMenu>
);
};
return (
<div className="mt-4">
{enableCalendarTasks && (
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
)}
</button>
)}
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? (
<button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
<div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)}
</div>
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
</div>
</div>
))}
{renderCalendarMenu()}
</div>
);
}
@@ -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<HTMLDivElement>(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<HTMLElement>(
'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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
onClick={() => !isSubmitting && onClose()}
aria-hidden="true"
/>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("add_calendar")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
</div>
<button
onClick={onClose}
disabled={isSubmitting}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name")}
</label>
<input
type="text"
value={name}
onChange={(e) => 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(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{tCommon("loading")}
</>
) : (
t("create")
)}
</Button>
</div>
</div>
</div>
);
}
+3
View File
@@ -35,6 +35,7 @@ interface EventModalProps {
calendars: Calendar[];
defaultDate?: Date;
defaultEndDate?: Date;
defaultCalendarId?: string;
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onDuplicate?: (data: Partial<CalendarEvent>) => 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<string>(() => {
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 || "";
});
+6 -2
View File
@@ -50,6 +50,7 @@ interface ContactFormProps {
contact?: ContactCard | null;
addressBooks?: AddressBook[];
allKeywords?: string[];
defaultAddressBookId?: string;
onSave: (data: Partial<ContactCard>) => Promise<void>;
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(() => {
+67 -20
View File
@@ -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}
/>
))}
</div>
@@ -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}
/>
))}
</div>
@@ -438,24 +444,65 @@ export function ContactsSidebar({
</div>
{/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
const book = bookContextMenu.data!;
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
</ContextMenu>
)}
{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 (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
{canCreate && (
<ContextMenuItem
icon={UserPlus}
label={t("address_books.new_contact_in_book")}
onClick={() => {
closeBookContextMenu();
onCreateContactInBook(book);
}}
/>
)}
{canRename && (
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
)}
{canShare && (
<ContextMenuItem
icon={Users}
label={t("address_books.share")}
onClick={() => {
closeBookContextMenu();
onShareAddressBook(book);
}}
/>
)}
{showSeparator && <ContextMenuSeparator />}
{canDelete && (
<ContextMenuItem
icon={Trash2}
label={t("address_books.delete")}
onClick={() => {
closeBookContextMenu();
onDeleteAddressBook(book);
}}
destructive
/>
)}
</ContextMenu>
);
})()}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
@@ -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<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -148,6 +150,16 @@ export function AddressBookManagementSettings() {
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{!book.isShared && book.myRights?.mayShare && (
<button
type="button"
onClick={() => setSharingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("share")}
>
<Users className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
@@ -242,6 +254,24 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
</div>
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingId(null)}
/>
);
})()}
</>
);
}
@@ -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<Record<string, string | null>>({});
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
@@ -164,6 +166,7 @@ export function CalendarManagementSettings() {
const [clearingId, setClearingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
@@ -522,6 +525,16 @@ export function CalendarManagementSettings() {
>
<Pencil className="w-3.5 h-3.5" />
</button>
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
<button
type="button"
onClick={() => setSharingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('share')}
>
<Users className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => setClearingId(cal.id)}
@@ -689,6 +702,24 @@ export function CalendarManagementSettings() {
onClose={() => setEditingSubscription(null)}
/>
)}
{sharingId && client && (() => {
const cal = calendars.find((c) => c.id === sharingId);
if (!cal) return null;
return (
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
}}
onClose={() => setSharingId(null)}
/>
);
})()}
</SettingsSection>
);
}
@@ -0,0 +1,347 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
import { toast } from "@/stores/toast-store";
type ShareKind = "calendar" | "addressBook";
type AnyRights = CalendarRights | AddressBookRights;
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
freeBusy: {
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
read: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
readWrite: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
},
manager: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
},
};
const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights> = {
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
};
function detectCalendarPreset(r: CalendarRights): RolePreset {
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
return name;
}
}
return "custom";
}
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
interface ShareCollectionDialogProps {
client: IJMAPClient;
kind: ShareKind;
collectionName: string;
shareWith: Record<string, AnyRights> | null | undefined;
ownAccountId: string;
onShare: (principalId: string, rights: AnyRights | null) => Promise<void>;
onClose: () => void;
}
export function ShareCollectionDialog({
client,
kind,
collectionName,
shareWith,
ownAccountId,
onShare,
onClose,
}: ShareCollectionDialogProps) {
const t = useTranslations("sharing");
const tCommon = useTranslations("common");
const modalRef = useRef<HTMLDivElement>(null);
const [principals, setPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
const [search, setSearch] = useState("");
const [savingId, setSavingId] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
// Load principals on mount
useEffect(() => {
let cancelled = false;
setLoadingPrincipals(true);
client.getPrincipals().then((list) => {
if (cancelled) return;
// Exclude the user themselves and any principal that already has a share
const existing = new Set(Object.keys(shareWith || {}));
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
setPrincipals(filtered);
setLoadingPrincipals(false);
}).catch(() => {
if (!cancelled) setLoadingPrincipals(false);
});
return () => { cancelled = true; };
}, [client, ownAccountId, shareWith]);
// Map principal id -> Principal for displayed shares
const allPrincipalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of principals) map.set(p.id, p);
return map;
}, [principals]);
// Close on Escape, focus trap, click outside
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
const rights = kind === "calendar"
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
if (!rights) return;
setSavingId(principalId);
try {
await onShare(principalId, rights);
toast.success(t("share_updated"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const handleRemove = async (principalId: string) => {
setSavingId(principalId);
try {
await onShare(principalId, null);
toast.success(t("share_removed"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const handleAdd = async (principal: Principal) => {
const defaultPreset: RolePreset = "read";
const rights = kind === "calendar"
? CALENDAR_PRESETS[defaultPreset]
: ADDRESS_BOOK_PRESETS[defaultPreset];
setSavingId(principal.id);
try {
await onShare(principal.id, rights);
// Move principal out of the "to add" list
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
setShowAdd(false);
setSearch("");
toast.success(t("share_added"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const filteredPrincipals = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return principals;
return principals.filter((p) =>
p.name.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q)
);
}, [principals, search]);
const sharedEntries = useMemo(() => {
return Object.entries(shareWith || {}) as [string, AnyRights][];
}, [shareWith]);
const presetOptions = kind === "calendar"
? ["freeBusy", "read", "readWrite", "manager"] as const
: ["read", "readWrite", "manager"] as const;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title", { name: collectionName })}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4 overflow-y-auto">
<p className="text-sm text-muted-foreground">{t("description")}</p>
{sharedEntries.length === 0 && !showAdd && (
<div className="text-sm text-muted-foreground italic py-4 text-center">
{t("no_shares")}
</div>
)}
{sharedEntries.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
{sharedEntries.map(([principalId, rights]) => {
const principal = allPrincipalsById.get(principalId);
const preset = kind === "calendar"
? detectCalendarPreset(rights as CalendarRights)
: detectAddressBookPreset(rights as AddressBookRights);
return (
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{principal?.name || principal?.email || principalId}
</div>
{principal?.description && (
<div className="text-xs text-muted-foreground truncate">
{principal.description}
</div>
)}
</div>
<div className="relative">
<select
value={preset}
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
disabled={savingId === principalId}
className="appearance-none rounded-md border border-input bg-background pl-3 pr-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{presetOptions.map((p) => (
<option key={p} value={p}>{t(`preset.${p}`)}</option>
))}
{preset === "custom" && (
<option value="custom">{t("preset.custom")}</option>
)}
</select>
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
</div>
<button
onClick={() => handleRemove(principalId)}
disabled={savingId === principalId}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
aria-label={t("remove")}
title={t("remove")}
>
{savingId === principalId
? <Loader2 className="w-4 h-4 animate-spin" />
: <Trash2 className="w-4 h-4" />}
</button>
</li>
);
})}
</ul>
)}
{!showAdd && (
<Button
variant="outline"
onClick={() => setShowAdd(true)}
className="w-full"
>
<UserPlus className="w-4 h-4 mr-2" />
{t("add_person")}
</Button>
)}
{showAdd && (
<div className="space-y-2 border border-border rounded-md p-3">
<input
type="text"
value={search}
onChange={(e) => 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
/>
<div className="max-h-48 overflow-y-auto -mx-1">
{loadingPrincipals && (
<div className="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("loading_principals")}
</div>
)}
{!loadingPrincipals && filteredPrincipals.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
{search.trim() ? t("no_match") : t("no_principals")}
</div>
)}
{!loadingPrincipals && filteredPrincipals.map((p) => (
<button
key={p.id}
onClick={() => handleAdd(p)}
disabled={savingId === p.id}
className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate flex items-center gap-2">
{p.name}
{p.type === "group" && (
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
{t("group")}
</span>
)}
</div>
{p.email && p.email !== p.name && (
<div className="text-xs text-muted-foreground truncate">{p.email}</div>
)}
</div>
{savingId === p.id && <Loader2 className="w-4 h-4 animate-spin" />}
</div>
</button>
))}
</div>
<div className="flex justify-end pt-1">
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setSearch(""); }}>
{tCommon("cancel")}
</Button>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button onClick={onClose}>{tCommon("close")}</Button>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayAdmin: false,
mayShare: false,
mayDelete: false,
},
...overrides,
+1 -1
View File
@@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayAdmin: false,
mayShare: false,
mayDelete: false,
},
};
+10 -1
View File
@@ -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<never[]> { return []; }
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
async setAddressBookShare(): Promise<void> { /* 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<void> {
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<ContactCard[]> {
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);
+3 -3
View File
@@ -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 },
},
];
}
+8 -1
View File
@@ -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<AddressBook[]>;
createAddressBook(name: string): Promise<AddressBook>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>;
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
@@ -227,6 +228,12 @@ export interface IJMAPClient {
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
supportsPrincipals(): boolean;
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string;
getSieveCapabilities(): SieveCapabilities | null;
+101 -1
View File
@@ -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<void> {
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<Principal[]> {
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<void> {
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<void> {
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<string, unknown>,
+15 -2
View File
@@ -368,6 +368,7 @@ export interface AddressBook {
isDefault?: boolean;
isSubscribed?: boolean;
myRights?: AddressBookRights;
shareWith?: Record<string, AddressBookRights> | 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<string, unknown>;
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;
}
+44 -13
View File
@@ -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í"
}
}
}
+33 -2
View File
@@ -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"
}
}
}
+33 -2
View File
@@ -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",
+33 -2
View File
@@ -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"
}
}
}
+33 -2
View File
@@ -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é"
}
}
}
+33 -2
View File
@@ -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"
}
}
}
+33 -2
View File
@@ -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": "カスタム"
}
}
}
+33 -2
View File
@@ -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": "사용자 지정"
}
}
}
+33 -2
View File
@@ -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"
}
}
}
+33 -2
View File
@@ -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"
}
}
}
+33 -2
View File
@@ -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"
}
}
}
+33 -2
View File
@@ -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"
}
}
}
+33 -2
View File
@@ -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": "Пользовательский"
}
}
}
+33 -2
View File
@@ -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": "Власне"
}
}
}
+33 -2
View File
@@ -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": "自定义"
}
}
}
+25 -1
View File
@@ -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<string, string> | null) => Promise<void>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
@@ -653,6 +654,29 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
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 {
+42 -1
View File
@@ -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<void>;
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise<void>;
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
@@ -655,6 +657,45 @@ export const useContactStore = create<ContactStore>()(
}
},
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();