feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy
- P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings - P2.5: Email import (.eml, .tgz, .zip) with dedup and progress - P2.6: Contact import (vCard + CSV) with auto-mapping - P2.7: Free/Busy view grid with color-coded slots
This commit is contained in:
@@ -35,6 +35,8 @@ import {
|
||||
SwatchBook,
|
||||
Download,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Share2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -71,6 +73,8 @@ import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings';
|
||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { ImportSettings } from '@/components/settings/import-settings';
|
||||
import { SharingSettings } from '@/components/settings/sharing-settings';
|
||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
@@ -115,6 +119,8 @@ type Tab =
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
| 'plugins'
|
||||
| 'import'
|
||||
| 'sharing'
|
||||
| 'ai_assistant'
|
||||
| 'debug';
|
||||
|
||||
@@ -159,6 +165,8 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
about_data: Info,
|
||||
themes: SwatchBook,
|
||||
plugins: Puzzle,
|
||||
import: Upload,
|
||||
sharing: Share2,
|
||||
ai_assistant: Sparkles,
|
||||
debug: Bug,
|
||||
};
|
||||
@@ -243,6 +251,8 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
themes: [],
|
||||
plugins: [],
|
||||
ai_assistant: [],
|
||||
import: ['settings.importer'],
|
||||
sharing: ['sharing'],
|
||||
debug: ['settings.advanced'],
|
||||
};
|
||||
|
||||
@@ -275,6 +285,8 @@ const tabKeywords: Record<Tab, string> = {
|
||||
themes: 'custom theme css skin appearance',
|
||||
plugins: 'extensions addons',
|
||||
ai_assistant: 'assistant ask model llm ollama chatbot',
|
||||
import: 'import email eml zip tgz mbox csv vcard contacts',
|
||||
sharing: 'share shared folder calendar address book permission',
|
||||
debug: 'logs developer console diagnostic',
|
||||
};
|
||||
|
||||
@@ -624,6 +636,7 @@ export default function SettingsPage() {
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'sharing', label: t('tabs.sharing'), icon: tabIcons.sharing, group: 'general' },
|
||||
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||
|
||||
// Appearance
|
||||
@@ -641,6 +654,7 @@ export default function SettingsPage() {
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||
{ id: 'import', label: t('tabs.import'), icon: tabIcons.import, group: 'mail' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||
|
||||
// Privacy & Security
|
||||
@@ -772,6 +786,8 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'import' && <ImportSettings />}
|
||||
{effectiveActiveTab === 'sharing' && <SharingSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
type JmapMethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
async function jmapRequest(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JmapMethodCall[],
|
||||
using?: string[],
|
||||
) {
|
||||
const sessionResp = await fetch(`${serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: authHeader },
|
||||
});
|
||||
if (!sessionResp.ok) {
|
||||
return { error: `Session fetch failed: ${sessionResp.status}` };
|
||||
}
|
||||
const session = await sessionResp.json();
|
||||
const apiUrl = session.apiUrl;
|
||||
if (!apiUrl) {
|
||||
return { error: "No API URL in JMAP session" };
|
||||
}
|
||||
|
||||
const body = {
|
||||
using: using || [
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:principals",
|
||||
],
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return { error: `JMAP request failed: ${resp.status}` };
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get("action");
|
||||
const serverUrl = request.headers.get("X-JMAP-Server-Url");
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
||||
if (!serverUrl || !authHeader) {
|
||||
return Response.json(
|
||||
{ error: "Missing server URL or auth header" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action !== "principals") {
|
||||
return Response.json(
|
||||
{ error: "Invalid action" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await jmapRequest(serverUrl, authHeader, [
|
||||
["Principal/query", { accountId: "" }, "0"],
|
||||
["Principal/get", {
|
||||
accountId: "",
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Principal/query",
|
||||
path: "/ids",
|
||||
},
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
if ("error" in result) {
|
||||
return Response.json(result, { status: 502 });
|
||||
}
|
||||
|
||||
const getResp = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
|
||||
const principals = getResp?.find((r) => r[0] === "Principal/get")?.[1]
|
||||
?.list ?? [];
|
||||
|
||||
return Response.json({ principals });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const serverUrl = request.headers.get("X-JMAP-Server-Url");
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
||||
if (!serverUrl || !authHeader) {
|
||||
return Response.json(
|
||||
{ error: "Missing server URL or auth header" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { kind, resourceId, principalId, role } = body;
|
||||
|
||||
if (!kind || !resourceId || !principalId) {
|
||||
return Response.json(
|
||||
{ error: "Missing required fields: kind, resourceId, principalId" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let method: string;
|
||||
let shareProperty: string;
|
||||
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
method = "Mailbox/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "calendar":
|
||||
method = "Calendar/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "addressBook":
|
||||
method = "AddressBook/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "file":
|
||||
method = "FileNode/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
default:
|
||||
return Response.json(
|
||||
{ error: `Invalid kind: ${kind}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const patchValue = role === null ? null : buildRights(kind as string, role as string);
|
||||
|
||||
const methodCalls: JmapMethodCall[] = [
|
||||
[
|
||||
method,
|
||||
{
|
||||
accountId: "",
|
||||
update: {
|
||||
[resourceId as string]: {
|
||||
[`${shareProperty}/${principalId}`]: patchValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
"0",
|
||||
],
|
||||
];
|
||||
|
||||
const result = await jmapRequest(
|
||||
serverUrl,
|
||||
authHeader,
|
||||
methodCalls,
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
return Response.json(result, { status: 502 });
|
||||
}
|
||||
|
||||
const responses = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
|
||||
const setResult = responses?.[0]?.[1];
|
||||
|
||||
if (
|
||||
setResult &&
|
||||
typeof setResult === "object" &&
|
||||
"notUpdated" in setResult &&
|
||||
setResult.notUpdated &&
|
||||
typeof setResult.notUpdated === "object" &&
|
||||
(resourceId as string) in setResult.notUpdated
|
||||
) {
|
||||
const err = (setResult.notUpdated as Record<string, Record<string, unknown>>)[resourceId as string];
|
||||
return Response.json(
|
||||
{ error: err.description || "Failed to update share" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
function buildRights(
|
||||
kind: string,
|
||||
role: string,
|
||||
): Record<string, boolean> | null {
|
||||
if (role === null) return null;
|
||||
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return mailboxRights(role);
|
||||
case "calendar":
|
||||
return calendarRights(role);
|
||||
case "addressBook":
|
||||
return addressBookRights(role);
|
||||
case "file":
|
||||
return fileRights(role);
|
||||
default:
|
||||
return readRights();
|
||||
}
|
||||
}
|
||||
|
||||
function mailboxRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: true,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
mayShare: true,
|
||||
};
|
||||
default:
|
||||
return mailboxRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function calendarRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
};
|
||||
default:
|
||||
return calendarRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function addressBookRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
};
|
||||
default:
|
||||
return addressBookRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function fileRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
mayModifyContent: false,
|
||||
mayShare: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: true,
|
||||
};
|
||||
default:
|
||||
return fileRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function readRights(): Record<string, boolean> {
|
||||
return { mayRead: true };
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import { FreeBusyView } from "./free-busy-view";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -351,6 +352,7 @@ export function EventModal({
|
||||
.map(p => ({ name: p.name, email: p.email }));
|
||||
});
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
@@ -1074,6 +1076,44 @@ export function EventModal({
|
||||
onAdd={handleAddAttendee}
|
||||
onRemove={handleRemoveAttendee}
|
||||
/>
|
||||
{attendees.length > 0 && !allDay && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFreeBusy((prev) => !prev)}
|
||||
className="text-xs"
|
||||
>
|
||||
{showFreeBusy ? (
|
||||
<EyeOff className="w-3.5 h-3.5 me-1" />
|
||||
) : (
|
||||
<Eye className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
{showFreeBusy ? t("freeBusy.hide") : t("freeBusy.check")}
|
||||
</Button>
|
||||
{showFreeBusy && (
|
||||
<div className="mt-3">
|
||||
<FreeBusyView
|
||||
participants={attendees}
|
||||
startDate={(() => {
|
||||
const d = new Date(`${startDate}T${startTime}:00`);
|
||||
return isNaN(d.getTime()) ? new Date() : d;
|
||||
})()}
|
||||
endDate={(() => {
|
||||
const d = new Date(`${endDate}T${endTime}:00`);
|
||||
return isNaN(d.getTime()) ? addHours(new Date(`${startDate}T${startTime}:00`), 8) : d;
|
||||
})()}
|
||||
onTimeSelect={(start, end) => {
|
||||
setStartDate(formatDateInput(start));
|
||||
setStartTime(formatTimeInput(start));
|
||||
setEndDate(formatDateInput(end));
|
||||
setEndTime(formatTimeInput(end));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t("participants.status_summary", {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { addMinutes, differenceInMinutes, format } from "date-fns";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy";
|
||||
|
||||
export interface FreeBusyViewProps {
|
||||
participants: { name?: string; email: string }[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onTimeSelect?: (start: Date, end: Date) => void;
|
||||
}
|
||||
|
||||
const SLOT_MINUTES = 30;
|
||||
const WORK_START_HOUR = 8;
|
||||
const WORK_END_HOUR = 18;
|
||||
|
||||
const statusColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800",
|
||||
busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800",
|
||||
tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800",
|
||||
unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800",
|
||||
unknown: "bg-muted border-muted-foreground/20",
|
||||
};
|
||||
|
||||
const statusHoverColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60",
|
||||
busy: "hover:bg-red-200 dark:hover:bg-red-800/60",
|
||||
tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60",
|
||||
unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60",
|
||||
unknown: "hover:bg-muted-foreground/20",
|
||||
};
|
||||
|
||||
function clampToSlot(d: Date): Date {
|
||||
const clone = new Date(d);
|
||||
clone.setSeconds(0, 0);
|
||||
const mins = clone.getMinutes();
|
||||
const remainder = mins % SLOT_MINUTES;
|
||||
if (remainder !== 0) {
|
||||
clone.setMinutes(mins - remainder, 0, 0);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] {
|
||||
const hours: { label: string; slots: FreeBusySlot[] }[] = [];
|
||||
let cursor = clampToSlot(start);
|
||||
while (cursor < end) {
|
||||
const hourEnd = new Date(cursor);
|
||||
hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0);
|
||||
const hourSlots: FreeBusySlot[] = [];
|
||||
let slotCursor = new Date(cursor);
|
||||
while (slotCursor < hourEnd && slotCursor < end) {
|
||||
const slotEnd = addMinutes(slotCursor, SLOT_MINUTES);
|
||||
hourSlots.push({
|
||||
start: new Date(slotCursor),
|
||||
end: slotEnd > end ? new Date(end) : slotEnd,
|
||||
status: "unknown",
|
||||
});
|
||||
slotCursor = slotEnd;
|
||||
}
|
||||
hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots });
|
||||
cursor = hourEnd;
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
function isWorkingHour(hour: number): boolean {
|
||||
return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR);
|
||||
}
|
||||
|
||||
export function FreeBusyView({
|
||||
participants,
|
||||
startDate,
|
||||
endDate,
|
||||
onTimeSelect,
|
||||
}: FreeBusyViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hoveredSlot, setHoveredSlot] = useState<{
|
||||
participant: string;
|
||||
slotIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]);
|
||||
const totalHalfHourSlots = useMemo(() => {
|
||||
let c = 0;
|
||||
for (const h of hourSlots) c += h.slots.length;
|
||||
return c;
|
||||
}, [hourSlots]);
|
||||
|
||||
const now = new Date();
|
||||
const showNowLine =
|
||||
now >= startDate && now <= endDate;
|
||||
const nowPositionPercent = showNowLine
|
||||
? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100))
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || participants.length === 0) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchFreeBusy(client, participants, startDate, endDate)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setFreeBusyData(data);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, participants, startDate, endDate]);
|
||||
|
||||
const handleSlotClick = useCallback(
|
||||
(slot: FreeBusySlot) => {
|
||||
if (slot.status === "free" && onTimeSelect) {
|
||||
onTimeSelect(new Date(slot.start), new Date(slot.end));
|
||||
}
|
||||
},
|
||||
[onTimeSelect]
|
||||
);
|
||||
|
||||
const timezone = useMemo(
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
[]
|
||||
);
|
||||
|
||||
if (participants.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("freeBusy.no_participants")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("freeBusy.timezone")}: {timezone}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-xs text-muted-foreground animate-pulse">
|
||||
{t("freeBusy.loading")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto border border-border rounded-lg">
|
||||
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-background border-b border-r border-border px-3 py-2 text-left w-[180px] min-w-[180px]">
|
||||
{t("participants.title")}
|
||||
</th>
|
||||
{hourSlots.map((hour, i) => (
|
||||
<th
|
||||
key={i}
|
||||
colSpan={hour.slots.length}
|
||||
className={cn(
|
||||
"border-b border-r border-border px-1 py-2 text-center font-medium",
|
||||
isWorkingHour(new Date(hour.slots[0]?.start).getHours())
|
||||
? "bg-muted/50"
|
||||
: "bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{hour.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.map((p) => {
|
||||
const key = p.email.toLowerCase();
|
||||
const slots = freeBusyData?.get(key);
|
||||
return (
|
||||
<tr key={key} className="border-b border-border">
|
||||
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={p.name}
|
||||
email={p.email}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{p.name || p.email}
|
||||
</div>
|
||||
{p.name && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{p.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{hourSlots.map((hour) =>
|
||||
hour.slots.map((hourSlot, si) => {
|
||||
const globalSlotIndex =
|
||||
hourSlots
|
||||
.slice(0, hourSlots.indexOf(hour))
|
||||
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
||||
|
||||
const slot = slots?.[globalSlotIndex];
|
||||
const status = slot?.status ?? "unknown";
|
||||
const isFree = status === "free";
|
||||
const isHovered =
|
||||
hoveredSlot?.participant === key &&
|
||||
hoveredSlot?.slotIndex === globalSlotIndex;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={si}
|
||||
className={cn(
|
||||
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
||||
statusColors[status],
|
||||
isFree && statusHoverColors[status],
|
||||
isFree && "cursor-pointer",
|
||||
isHovered && "ring-1 ring-inset ring-primary/50",
|
||||
isWorkingHour(new Date(hourSlot.start).getHours())
|
||||
? ""
|
||||
: "opacity-70"
|
||||
)}
|
||||
title={format(hourSlot.start, "HH:mm")}
|
||||
onClick={() =>
|
||||
isFree ? handleSlotClick(slot!) : undefined
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
setHoveredSlot({
|
||||
participant: key,
|
||||
slotIndex: globalSlotIndex,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setHoveredSlot(null)}
|
||||
>
|
||||
{status === "free" && (
|
||||
<span className="block w-full h-full"> </span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNowLine && nowPositionPercent !== null && (
|
||||
<div
|
||||
className="absolute pointer-events-none z-20"
|
||||
style={{
|
||||
left: `calc(180px + ${nowPositionPercent}% * (1 - 180px / ${totalHalfHourSlots * 24 + 200}))`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-emerald-200 dark:border-emerald-800 bg-emerald-100 dark:bg-emerald-900/40" />
|
||||
{t("freeBusy.free")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-red-200 dark:border-red-800 bg-red-100 dark:bg-red-900/40" />
|
||||
{t("freeBusy.busy")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-amber-200 dark:border-amber-800 bg-amber-100 dark:bg-amber-900/40" />
|
||||
{t("freeBusy.tentative")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-purple-200 dark:border-purple-800 bg-purple-100 dark:bg-purple-900/40" />
|
||||
{t("freeBusy.unavailable")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-muted-foreground/20 bg-muted" />
|
||||
{t("freeBusy.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,26 +2,39 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
|
||||
import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import {
|
||||
parseCSV,
|
||||
autoMapColumns,
|
||||
mapRowToContact,
|
||||
detectDuplicatesByEmail,
|
||||
type CsvColumnMapping,
|
||||
type CsvParseResult,
|
||||
} from "@/lib/contact-csv-import";
|
||||
|
||||
type FileType = "vcf" | "csv" | null;
|
||||
|
||||
interface ContactImportDialogProps {
|
||||
existingContacts: ContactCard[];
|
||||
addressBooks?: AddressBook[];
|
||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ContactImportDialog({
|
||||
existingContacts,
|
||||
addressBooks,
|
||||
onImport,
|
||||
onClose,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [fileType, setFileType] = useState<FileType>(null);
|
||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
|
||||
@@ -29,19 +42,60 @@ export function ContactImportDialog({
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [csvData, setCsvData] = useState<CsvParseResult | null>(null);
|
||||
const [mapping, setMapping] = useState<CsvColumnMapping | null>(null);
|
||||
const [targetBookId, setTargetBookId] = useState("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const ALLOWED_ACCEPT = ".vcf,.vcard,.csv,text/csv,text/vcard";
|
||||
|
||||
const books = addressBooks || [];
|
||||
const defaultBookId =
|
||||
books.find((b) => b.isDefault)?.id || books[0]?.id || "";
|
||||
const effectiveBookId = targetBookId || defaultBookId;
|
||||
const bookOptions = books.map((b) => ({
|
||||
value: b.id,
|
||||
label: b.name,
|
||||
}));
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setFileType(null);
|
||||
setParsed([]);
|
||||
setSelected(new Set());
|
||||
setDuplicates(new Map());
|
||||
setCsvData(null);
|
||||
setMapping(null);
|
||||
setShowPreview(false);
|
||||
setTargetBookId("");
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError(t("import.file_too_large"));
|
||||
return;
|
||||
}
|
||||
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
try {
|
||||
if (name.endsWith(".csv") || file.type === "text/csv") {
|
||||
setFileType("csv");
|
||||
const text = await file.text();
|
||||
const result = parseCSV(text);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvData(result);
|
||||
setMapping(autoMapColumns(result.headers));
|
||||
setTargetBookId(defaultBookId);
|
||||
} else {
|
||||
setFileType("vcf");
|
||||
const text = await file.text();
|
||||
const contacts = parseVCard(text);
|
||||
|
||||
@@ -59,11 +113,40 @@ export function ContactImportDialog({
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse vCard:', error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to parse file:", err);
|
||||
setError(t("import.parse_error"));
|
||||
}
|
||||
}, [existingContacts, t]);
|
||||
}, [existingContacts, t, defaultBookId]);
|
||||
|
||||
const applyCsvMapping = useCallback(() => {
|
||||
if (!csvData || !mapping) return;
|
||||
|
||||
const bookIds = effectiveBookId ? { [effectiveBookId]: true } : {};
|
||||
const contacts: ContactCard[] = [];
|
||||
|
||||
for (const row of csvData.rows) {
|
||||
const contact = mapRowToContact(row, mapping, bookIds);
|
||||
if (contact) contacts.push(contact);
|
||||
}
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const dupes = detectDuplicatesByEmail(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
setShowPreview(true);
|
||||
}, [csvData, mapping, effectiveBookId, existingContacts, t]);
|
||||
|
||||
const toggleSelect = (idx: number) => {
|
||||
const next = new Set(selected);
|
||||
@@ -91,14 +174,160 @@ export function ContactImportDialog({
|
||||
try {
|
||||
const count = await onImport(toImport);
|
||||
setResult(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to import contacts:', error);
|
||||
} catch (err) {
|
||||
console.error("Failed to import contacts:", err);
|
||||
setError(t("import.failed"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCsvMapping = () => {
|
||||
if (!csvData || !mapping) return null;
|
||||
|
||||
const fields: Array<{ key: keyof CsvColumnMapping; label: string }> = [
|
||||
{ key: "firstName", label: t("import.csv_first_name") },
|
||||
{ key: "lastName", label: t("import.csv_last_name") },
|
||||
{ key: "email", label: t("import.csv_email") },
|
||||
{ key: "phone", label: t("import.csv_phone") },
|
||||
{ key: "company", label: t("import.csv_company") },
|
||||
{ key: "jobTitle", label: t("import.csv_job_title") },
|
||||
{ key: "address", label: t("import.csv_address") },
|
||||
{ key: "city", label: t("import.csv_city") },
|
||||
{ key: "region", label: t("import.csv_region") },
|
||||
{ key: "postcode", label: t("import.csv_postcode") },
|
||||
{ key: "country", label: t("import.csv_country") },
|
||||
{ key: "website", label: t("import.csv_website") },
|
||||
{ key: "note", label: t("import.csv_note") },
|
||||
{ key: "nickname", label: t("import.csv_nickname") },
|
||||
];
|
||||
|
||||
const headerOptions = csvData.headers.map((h, i) => ({
|
||||
value: String(i),
|
||||
label: h,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{t("import.csv_map_columns")}</p>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
|
||||
{fields.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground w-24 flex-shrink-0 truncate">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={mapping[key] >= 0 ? String(mapping[key]) : "-1"}
|
||||
onChange={(e) => {
|
||||
setMapping((prev) => prev ? {
|
||||
...prev,
|
||||
[key]: parseInt(e.target.value, 10),
|
||||
} : null);
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
dir="auto"
|
||||
>
|
||||
<option value="-1">{t("import.csv_ignore")}</option>
|
||||
{headerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{books.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<label className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{t("import.csv_address_book")}
|
||||
</label>
|
||||
<select
|
||||
value={effectiveBookId}
|
||||
onChange={(e) => setTargetBookId(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
dir="auto"
|
||||
>
|
||||
{bookOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button size="sm" onClick={applyCsvMapping}>
|
||||
{t("import.csv_preview")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFileType(null);
|
||||
setCsvData(null);
|
||||
setMapping(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCsvPreview = () => {
|
||||
if (!csvData || !mapping || !showPreview) return null;
|
||||
const previewRows = csvData.rows.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{t("import.csv_preview_title", { count: parsed.length })}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(false)}
|
||||
>
|
||||
{t("import.csv_back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-md overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
{csvData.headers.map((h, i) => (
|
||||
<th key={i} className="px-2 py-1.5 text-start font-medium text-muted-foreground whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewRows.map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-border">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-2 py-1.5 truncate max-w-[150px]">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={applyCsvMapping}>
|
||||
{t("import.csv_load_all")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
@@ -119,12 +348,12 @@ export function ContactImportDialog({
|
||||
{t("import.close")}
|
||||
</Button>
|
||||
</div>
|
||||
) : parsed.length === 0 ? (
|
||||
) : fileType === null ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".vcf,.vcard"
|
||||
accept={ALLOWED_ACCEPT}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -141,7 +370,7 @@ export function ContactImportDialog({
|
||||
>
|
||||
<Upload className="w-8 h-8" />
|
||||
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
|
||||
<p className="text-xs">{t("import.file_types")}</p>
|
||||
<p className="text-xs">{t("import.file_types_csv")}</p>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
@@ -151,6 +380,10 @@ export function ContactImportDialog({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : fileType === "csv" && csvData && !showPreview ? (
|
||||
renderCsvMapping()
|
||||
) : fileType === "csv" && csvData && showPreview ? (
|
||||
renderCsvPreview()
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
@@ -217,7 +450,23 @@ export function ContactImportDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsed.length > 0 && result === null && (
|
||||
{parsed.length > 0 && result === null && fileType !== "csv" && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
|
||||
{isImporting ? t("import.importing") : t("import.import_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileType === "csv" && showPreview && parsed.length > 0 && result === null && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FolderX,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Position {
|
||||
@@ -86,6 +87,7 @@ interface MailboxContextMenuProps {
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,7 @@ export function MailboxContextMenu({
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onShareFolder,
|
||||
onRefresh,
|
||||
}: MailboxContextMenuProps) {
|
||||
const t = useTranslations("mailbox_context_menu");
|
||||
@@ -191,6 +194,12 @@ export function MailboxContextMenu({
|
||||
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||
disabled={!onRenameFolder || !canRename}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Share2}
|
||||
label={t("share_folder")}
|
||||
onClick={() => handleAction(() => onShareFolder?.(mailbox.id))}
|
||||
disabled={!onShareFolder || mailbox.isShared}
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ interface SidebarProps {
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
/** True when the unified view spans multiple login accounts (cross-account).
|
||||
@@ -779,6 +780,7 @@ export function Sidebar({
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
onShareFolder,
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
crossAccountActive = false,
|
||||
@@ -1452,6 +1454,7 @@ export function Sidebar({
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onImportEmail={onImportEmail}
|
||||
onShareFolder={onShareFolder}
|
||||
onRefresh={onRefreshMailboxes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ export function ContactsSettings() {
|
||||
const { client } = useAuthStore();
|
||||
const {
|
||||
contacts,
|
||||
addressBooks,
|
||||
supportsSync,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
@@ -46,6 +47,7 @@ export function ContactsSettings() {
|
||||
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
addressBooks={addressBooks}
|
||||
onImport={handleImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FolderOpen, Download, AlertTriangle, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section";
|
||||
import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { EML_IMPORT_ACCEPT } from "@/lib/eml-import";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ImportSettings() {
|
||||
const t = useTranslations("settings.importer");
|
||||
const { client } = useAuthStore();
|
||||
const { mailboxes } = useEmailStore();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [destination, setDestination] = useState("");
|
||||
const [conflict, setConflict] = useState<ConflictResolution>("skip");
|
||||
const [progress, setProgress] = useState<ImportProgress | null>(null);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (mailboxes.length > 0 && !destination) {
|
||||
const inbox = mailboxes.find((m) => m.role === "inbox") || mailboxes[0];
|
||||
if (inbox) setDestination(inbox.id);
|
||||
}
|
||||
}, [mailboxes, destination]);
|
||||
|
||||
const folderOptions = mailboxes.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
}));
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files;
|
||||
if (!selected || selected.length === 0) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(null);
|
||||
setFiles(Array.from(selected));
|
||||
}, []);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!client || files.length === 0 || !destination) return;
|
||||
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await importEmails({
|
||||
client,
|
||||
files,
|
||||
destinationMailboxId: destination,
|
||||
conflictResolution: conflict,
|
||||
onProgress: (p) => setProgress({ ...p }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
setResult(res);
|
||||
if (res.imported > 0) {
|
||||
toast.success(t("success", { count: res.imported }));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) {
|
||||
const msg = err instanceof Error ? err.message : t("fail");
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [client, files, destination, conflict, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
setImporting(false);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFiles([]);
|
||||
setResult(null);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
const progressPercent = progress && progress.total > 0
|
||||
? Math.round((progress.processed / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
>
|
||||
<SettingItem
|
||||
label={t("file_label")}
|
||||
description={t("file_description")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={EML_IMPORT_ACCEPT}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
<Upload className="w-4 h-4 me-2" />
|
||||
{files.length > 0
|
||||
? t("files_selected", { count: files.length })
|
||||
: t("choose_files")}
|
||||
</Button>
|
||||
{files.length > 0 && !importing && (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("folder_label")}
|
||||
description={t("folder_description")}
|
||||
>
|
||||
<Select
|
||||
value={destination}
|
||||
onChange={setDestination}
|
||||
options={folderOptions}
|
||||
disabled={importing || folderOptions.length === 0}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("conflict_label")}
|
||||
description={t("conflict_description")}
|
||||
>
|
||||
<RadioGroup
|
||||
value={conflict}
|
||||
onChange={(v) => setConflict(v as ConflictResolution)}
|
||||
options={[
|
||||
{ value: "skip", label: t("conflict_skip") },
|
||||
{ value: "replace", label: t("conflict_replace") },
|
||||
{ value: "copy", label: t("conflict_copy") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{files.length > 0 && !result && (
|
||||
<SettingItem label={t("action_label")} description="">
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !destination}
|
||||
>
|
||||
{importing ? t("importing") : t("start_import", { count: files.length })}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && importing && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{progress.currentFile}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{t("progress_imported", { count: progress.imported })}</span>
|
||||
<span>{t("progress_skipped", { count: progress.skipped })}</span>
|
||||
<span>{t("progress_failed", { count: progress.failed })}</span>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" onClick={handleCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !importing && (
|
||||
<div className={cn(
|
||||
"rounded-lg p-4 space-y-3",
|
||||
result.failed > 0
|
||||
? "bg-warning/10 border border-warning/30"
|
||||
: "bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
<span className="font-medium text-sm">{t("import_complete")}</span>
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>{t("summary_imported", { count: result.imported })}</p>
|
||||
<p>{t("summary_skipped", { count: result.skipped })}</p>
|
||||
<p>{t("summary_failed", { count: result.failed })}</p>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{t("error_details", { count: result.errors.length })}
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1 ps-4 list-disc">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i} className="text-red-600 dark:text-red-400">
|
||||
<span className="font-medium">{e.file}</span>: {e.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={reset}>
|
||||
{t("import_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Check,
|
||||
X,
|
||||
Folder,
|
||||
Calendar,
|
||||
BookUser,
|
||||
HardDrive,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSharingStore, type SharedResourceKind, type SharedFolder } from "@/stores/sharing-store";
|
||||
|
||||
const ICON_CLASS = "w-4 h-4 shrink-0";
|
||||
|
||||
function KindIcon({ kind }: { kind: SharedResourceKind }) {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return <Folder className={cn(ICON_CLASS, "text-blue-600/80")} />;
|
||||
case "calendar":
|
||||
return <Calendar className={cn(ICON_CLASS, "text-emerald-600/80")} />;
|
||||
case "addressBook":
|
||||
return <BookUser className={cn(ICON_CLASS, "text-violet-600/80")} />;
|
||||
case "file":
|
||||
return <HardDrive className={cn(ICON_CLASS, "text-amber-600/80")} />;
|
||||
}
|
||||
}
|
||||
|
||||
function KindLabel({ kind }: { kind: SharedResourceKind }) {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return "Mail";
|
||||
case "calendar":
|
||||
return "Calendar";
|
||||
case "addressBook":
|
||||
return "Contacts";
|
||||
case "file":
|
||||
return "Files";
|
||||
}
|
||||
}
|
||||
|
||||
export function SharingSettings() {
|
||||
const t = useTranslations("settings");
|
||||
const tSharing = useTranslations("sharing");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const {
|
||||
sharedByMe,
|
||||
sharedWithMe,
|
||||
loading,
|
||||
fetchShares,
|
||||
revokeShare,
|
||||
changeRole,
|
||||
acceptShare,
|
||||
declineShare,
|
||||
} = useSharingStore();
|
||||
const [activeTab, setActiveTab] = useState<"byMe" | "withMe">("byMe");
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (client) fetchShares(client);
|
||||
}, [client, fetchShares]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client) handleRefresh();
|
||||
}, [client, handleRefresh]);
|
||||
|
||||
const handleRevoke = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await revokeShare(
|
||||
client,
|
||||
share.resourceId,
|
||||
share.resourceKind,
|
||||
share.principalId,
|
||||
share.accountId,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeRole = async (share: SharedFolder, role: string) => {
|
||||
if (!client) return;
|
||||
await changeRole(
|
||||
client,
|
||||
share.resourceId,
|
||||
share.resourceKind,
|
||||
share.principalId,
|
||||
role,
|
||||
share.accountId,
|
||||
);
|
||||
};
|
||||
|
||||
const handleAccept = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await acceptShare(client, share);
|
||||
};
|
||||
|
||||
const handleDecline = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await declineShare(client, share);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 border-b border-border mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab("byMe")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "byMe"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tSharing("tab_shared_by_me")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("withMe")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "withMe"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tSharing("tab_shared_with_me")}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-md hover:bg-muted text-muted-foreground disabled:opacity-50 transition-colors"
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-4 h-4", loading && "animate-spin")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
{t("loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "byMe" && (
|
||||
<>
|
||||
{sharedByMe.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{tSharing("no_shares_by_me")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sharedByMe.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
|
||||
>
|
||||
<KindIcon kind={share.resourceKind} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{share.resourceName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KindLabel kind={share.resourceKind} />
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<Avatar
|
||||
name={share.principalName}
|
||||
email={share.principalEmail ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0 me-1"
|
||||
/>
|
||||
<span className="truncate">{share.principalName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={share.role}
|
||||
onChange={(e) => handleChangeRole(share, e.target.value)}
|
||||
className="appearance-none rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="read">
|
||||
{tSharing("preset.read")}
|
||||
</option>
|
||||
<option value="readWrite">
|
||||
{tSharing("preset.readWrite")}
|
||||
</option>
|
||||
<option value="manager">
|
||||
{tSharing("preset.manager")}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRevoke(share)}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={tSharing("remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "withMe" && (
|
||||
<>
|
||||
{sharedWithMe.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{tSharing("no_shares_with_me")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sharedWithMe.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
|
||||
>
|
||||
<KindIcon kind={share.resourceKind} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{share.resourceName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KindLabel kind={share.resourceKind} />
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<span className="truncate">
|
||||
{tSharing("shared_by")}: {share.principalName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs bg-muted rounded px-2 py-0.5 text-muted-foreground">
|
||||
{tSharing(`preset.${share.role}`)}
|
||||
</span>
|
||||
{share.pending ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handleAccept(share)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Check className="w-3 h-3 me-1" />
|
||||
{tSharing("accept")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDecline(share)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<X className="w-3 h-3 me-1" />
|
||||
{tSharing("decline")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDecline(share)}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{tSharing("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import {
|
||||
X,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Users,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Principal } from "@/lib/jmap/types";
|
||||
import { useSharingStore, type SharedResourceKind } from "@/stores/sharing-store";
|
||||
|
||||
export interface ShareFolderDialogProps {
|
||||
client: IJMAPClient;
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
resourceKind: SharedResourceKind;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PRESET_OPTIONS: Record<SharedResourceKind, readonly string[]> = {
|
||||
mailbox: ["read", "readWrite", "manager"],
|
||||
calendar: ["read", "readWrite", "manager"],
|
||||
addressBook: ["read", "readWrite", "manager"],
|
||||
file: ["read", "readWrite", "manager"],
|
||||
};
|
||||
|
||||
export function ShareFolderDialog({
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
onClose,
|
||||
}: ShareFolderDialogProps) {
|
||||
const t = useTranslations("sharing");
|
||||
const tCommon = useTranslations("common");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sharedByMe = useSharingStore((s) => s.sharedByMe);
|
||||
const loadPrincipals = useSharingStore((s) => s.loadPrincipals);
|
||||
const shareFolder = useSharingStore((s) => s.shareFolder);
|
||||
const revokeShare = useSharingStore((s) => s.revokeShare);
|
||||
const changeRole = useSharingStore((s) => s.changeRole);
|
||||
|
||||
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingPrincipals(true);
|
||||
loadPrincipals(client)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setAllPrincipals(list);
|
||||
setLoadingPrincipals(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadingPrincipals(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, loadPrincipals]);
|
||||
|
||||
const ownAccountId = client.getAccountId();
|
||||
|
||||
const allPrincipalsById = useMemo(() => {
|
||||
const map = new Map<string, Principal>();
|
||||
for (const p of allPrincipals) map.set(p.id, p);
|
||||
return map;
|
||||
}, [allPrincipals]);
|
||||
|
||||
const currentShares = sharedByMe.filter(
|
||||
(f) => f.resourceId === resourceId && f.resourceKind === resourceKind,
|
||||
);
|
||||
|
||||
const principals = useMemo(() => {
|
||||
const existing = new Set(currentShares.map((s) => s.principalId));
|
||||
return allPrincipals.filter(
|
||||
(p) => p.id !== ownAccountId && !existing.has(p.id),
|
||||
);
|
||||
}, [allPrincipals, ownAccountId, currentShares]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleRemove = async (principalId: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await revokeShare(client, resourceId, resourceKind, principalId);
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = async (principalId: string, role: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await changeRole(client, resourceId, resourceKind, principalId, role);
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (principal: Principal) => {
|
||||
setSavingId(principal.id);
|
||||
try {
|
||||
await shareFolder(
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
principal.id,
|
||||
"read",
|
||||
message || undefined,
|
||||
);
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
setMessage("");
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} 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 presetOptions = PRESET_OPTIONS[resourceKind];
|
||||
|
||||
const kindLabels: Record<SharedResourceKind, string> = {
|
||||
mailbox: "Mail folder",
|
||||
calendar: "Calendar",
|
||||
addressBook: "Address book",
|
||||
file: "File folder",
|
||||
};
|
||||
|
||||
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: resourceName })}
|
||||
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" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("title", { name: resourceName })}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kindLabels[resourceKind]}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{currentShares.length === 0 && !showAdd && (
|
||||
<div className="text-sm text-muted-foreground italic py-4 text-center">
|
||||
{t("no_shares")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentShares.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
||||
{currentShares.map((share) => {
|
||||
const principal = allPrincipalsById.get(share.principalId);
|
||||
return (
|
||||
<li
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5"
|
||||
>
|
||||
<Avatar
|
||||
name={principal?.name}
|
||||
email={principal?.email ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{principal?.name ||
|
||||
principal?.email ||
|
||||
share.principalId}
|
||||
</div>
|
||||
{principal?.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{principal.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={share.role}
|
||||
onChange={(e) =>
|
||||
handleChangeRole(share.principalId, e.target.value)
|
||||
}
|
||||
disabled={savingId === share.principalId}
|
||||
className="appearance-none rounded-md border border-input bg-background ps-3 pe-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>
|
||||
))}
|
||||
{share.role === "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(share.principalId)}
|
||||
disabled={savingId === share.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 === share.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 me-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 me-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-start px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={p.name}
|
||||
email={p.email ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<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>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Optional message…"
|
||||
rows={2}
|
||||
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 resize-none"
|
||||
/>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { addMinutes } from "date-fns";
|
||||
|
||||
export interface FreeBusySlot {
|
||||
start: Date;
|
||||
end: Date;
|
||||
status: "free" | "busy" | "tentative" | "unavailable" | "unknown";
|
||||
}
|
||||
|
||||
const SLOT_MINUTES = 30;
|
||||
|
||||
function clampToSlotStart(d: Date): Date {
|
||||
const clone = new Date(d);
|
||||
clone.setSeconds(0, 0);
|
||||
const mins = clone.getMinutes();
|
||||
const remainder = mins % SLOT_MINUTES;
|
||||
if (remainder !== 0) {
|
||||
clone.setMinutes(mins - remainder, 0, 0);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function buildSlots(start: Date, end: Date): FreeBusySlot[] {
|
||||
const slots: FreeBusySlot[] = [];
|
||||
let cursor = new Date(start);
|
||||
while (cursor < end) {
|
||||
const slotEnd = addMinutes(cursor, SLOT_MINUTES);
|
||||
slots.push({
|
||||
start: new Date(cursor),
|
||||
end: slotEnd > end ? new Date(end) : slotEnd,
|
||||
status: "unknown",
|
||||
});
|
||||
cursor = slotEnd;
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
interface EventRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
freeBusyStatus: CalendarEvent["freeBusyStatus"];
|
||||
eventStatus: CalendarEvent["status"];
|
||||
}
|
||||
|
||||
function getEventRange(event: CalendarEvent): EventRange {
|
||||
return {
|
||||
start: new Date(event.start),
|
||||
end: new Date(new Date(event.start).getTime() + parseDurationMs(event.duration)),
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
eventStatus: event.status,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDurationMs(duration: string): number {
|
||||
let ms = 0;
|
||||
let sign = 1;
|
||||
let s = duration;
|
||||
if (s.startsWith("-")) {
|
||||
sign = -1;
|
||||
s = s.slice(1);
|
||||
}
|
||||
if (s.startsWith("+")) s = s.slice(1);
|
||||
if (!s.startsWith("P")) return 0;
|
||||
s = s.slice(1);
|
||||
const tIdx = s.indexOf("T");
|
||||
const datePart = tIdx >= 0 ? s.slice(0, tIdx) : s;
|
||||
const timePart = tIdx >= 0 ? s.slice(tIdx + 1) : "";
|
||||
|
||||
let num = "";
|
||||
for (const ch of datePart) {
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
num += ch;
|
||||
} else {
|
||||
const v = parseInt(num, 10) || 0;
|
||||
if (ch === "W") ms += v * 7 * 24 * 60 * 60 * 1000;
|
||||
else if (ch === "D") ms += v * 24 * 60 * 60 * 1000;
|
||||
num = "";
|
||||
}
|
||||
}
|
||||
|
||||
for (const ch of timePart) {
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
num += ch;
|
||||
} else {
|
||||
const v = parseInt(num, 10) || 0;
|
||||
if (ch === "H") ms += v * 60 * 60 * 1000;
|
||||
else if (ch === "M") ms += v * 60 * 1000;
|
||||
else if (ch === "S") ms += v * 1000;
|
||||
num = "";
|
||||
}
|
||||
}
|
||||
|
||||
return ms * sign;
|
||||
}
|
||||
|
||||
function eventsOverlap(eventStart: Date, eventEnd: Date, slotStart: Date, slotEnd: Date): boolean {
|
||||
return eventStart < slotEnd && eventEnd > slotStart;
|
||||
}
|
||||
|
||||
function slotStatusFromEvent(
|
||||
event: CalendarEvent,
|
||||
participantStatus: string | null
|
||||
): FreeBusySlot["status"] {
|
||||
if (event.status === "cancelled") return "free";
|
||||
|
||||
if (participantStatus === "declined") return "free";
|
||||
if (participantStatus === "tentative") return "tentative";
|
||||
|
||||
if (event.freeBusyStatus === "free") return "free";
|
||||
if (event.freeBusyStatus === "busy") return "busy";
|
||||
|
||||
if (participantStatus === "accepted") return "busy";
|
||||
if (participantStatus === "needs-action") return "tentative";
|
||||
|
||||
return "busy";
|
||||
}
|
||||
|
||||
export async function fetchFreeBusy(
|
||||
client: IJMAPClient,
|
||||
participants: { email: string }[],
|
||||
start: Date,
|
||||
end: Date
|
||||
): Promise<Map<string, FreeBusySlot[]>> {
|
||||
const result = new Map<string, FreeBusySlot[]>();
|
||||
|
||||
const slots = buildSlots(clampToSlotStart(start), end);
|
||||
|
||||
for (const p of participants) {
|
||||
const key = p.email.toLowerCase();
|
||||
const participantSlots: FreeBusySlot[] = slots.map((s) => ({
|
||||
start: new Date(s.start),
|
||||
end: new Date(s.end),
|
||||
status: "unknown" as const,
|
||||
}));
|
||||
result.set(key, participantSlots);
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await client.queryAllCalendarEvents(
|
||||
{ after: start.toISOString(), before: end.toISOString() },
|
||||
[{ property: "start", isAscending: true }]
|
||||
);
|
||||
|
||||
for (const event of events) {
|
||||
if (event.status === "cancelled") continue;
|
||||
if (!event.participants) continue;
|
||||
|
||||
const range = getEventRange(event);
|
||||
|
||||
for (const key of result.keys()) {
|
||||
const participant = Object.values(event.participants).find(
|
||||
(p) => p.email.toLowerCase() === key
|
||||
);
|
||||
if (!participant) continue;
|
||||
|
||||
const status = slotStatusFromEvent(event, participant.participationStatus);
|
||||
const participantSlots = result.get(key)!;
|
||||
|
||||
for (const slot of participantSlots) {
|
||||
if (eventsOverlap(range.start, range.end, slot.start, slot.end)) {
|
||||
if (status === "busy" || slot.status === "unknown") {
|
||||
slot.status = status;
|
||||
} else if (status === "tentative" && slot.status === "free") {
|
||||
slot.status = "tentative";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Return unknown statuses for all slots on fetch failure
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isWorkingHour(hour: number, workStart = 8, workEnd = 18): boolean {
|
||||
return hour >= workStart && hour < workEnd;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { ContactCard, NameComponent } from "@/lib/jmap/types";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export interface CsvColumnMapping {
|
||||
firstName: number;
|
||||
lastName: number;
|
||||
email: number;
|
||||
phone: number;
|
||||
company: number;
|
||||
jobTitle: number;
|
||||
address: number;
|
||||
city: number;
|
||||
region: number;
|
||||
postcode: number;
|
||||
country: number;
|
||||
website: number;
|
||||
note: number;
|
||||
nickname: number;
|
||||
}
|
||||
|
||||
export interface CsvParseResult {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
delimiter: string;
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
function detectDelimiter(text: string): string {
|
||||
const line = text.split("\n")[0] || "";
|
||||
const counts: Record<string, number> = { ",": 0, ";": 0, "\t": 0 };
|
||||
|
||||
for (const ch of line) {
|
||||
if (ch in counts) counts[ch]++;
|
||||
}
|
||||
|
||||
const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
||||
return best && best[1] > 0 ? best[0] : ",";
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string, delimiter: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (i + 1 < line.length && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === delimiter) {
|
||||
fields.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
fields.push(current.trim());
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function parseCSV(text: string): CsvParseResult {
|
||||
const delimiter = detectDelimiter(text);
|
||||
const rawLines = text.split(/\r?\n/);
|
||||
|
||||
const headers = parseCsvLine(rawLines[0] || "", delimiter);
|
||||
const rows: string[][] = [];
|
||||
|
||||
for (let i = 1; i < rawLines.length; i++) {
|
||||
const line = rawLines[i].trim();
|
||||
if (!line) continue;
|
||||
const fields = parseCsvLine(line, delimiter);
|
||||
if (fields.length > 0 && fields.some((f) => f.length > 0)) {
|
||||
rows.push(fields);
|
||||
}
|
||||
}
|
||||
|
||||
return { headers, rows, delimiter, totalRows: rows.length };
|
||||
}
|
||||
|
||||
const NAME_PATTERNS = [
|
||||
/^(?:first[\s_-]?name|given[\s_-]?name|forename|vorname|prénom|nombre|名)$/i,
|
||||
];
|
||||
const LAST_NAME_PATTERNS = [
|
||||
/^(?:last[\s_-]?name|surname|family[\s_-]?name|nachname|nom|姓)$/i,
|
||||
];
|
||||
const EMAIL_PATTERNS = [
|
||||
/^(?:e?-?mail|email[\s_-]?address|e?-?mail[\s_-]?address|e-mail-adresse)$/i,
|
||||
];
|
||||
const PHONE_PATTERNS = [
|
||||
/^(?:phone|telephone|tel|mobile|cell|handy|telefon|téléphone|电话)$/i,
|
||||
];
|
||||
const COMPANY_PATTERNS = [
|
||||
/^(?:company|organization|org|firma|unternehmen|entreprise|société|公司)$/i,
|
||||
];
|
||||
const JOB_TITLE_PATTERNS = [
|
||||
/^(?:job[\s_-]?title|title|position|role|funktion|beruf|poste)$/i,
|
||||
];
|
||||
const ADDRESS_PATTERNS = [
|
||||
/^(?:address|addr|street|straße|adresse|rue)$/i,
|
||||
];
|
||||
const CITY_PATTERNS = [
|
||||
/^(?:city|town|ort|stadt|ville)$/i,
|
||||
];
|
||||
const REGION_PATTERNS = [
|
||||
/^(?:state|province|region|bundesland|région)$/i,
|
||||
];
|
||||
const POSTCODE_PATTERNS = [
|
||||
/^(?:zip|postal[\s_-]?code|postcode|plz|code[\s_-]?postal)$/i,
|
||||
];
|
||||
const COUNTRY_PATTERNS = [
|
||||
/^(?:country|land|pays)$/i,
|
||||
];
|
||||
const WEBSITE_PATTERNS = [
|
||||
/^(?:website|url|web|homepage|site)$/i,
|
||||
];
|
||||
const NOTE_PATTERNS = [
|
||||
/^(?:note|notes|comments|bemerkung|notiz|remarque)$/i,
|
||||
];
|
||||
const NICKNAME_PATTERNS = [
|
||||
/^(?:nickname|nick|alias|spitzname|surnom)$/i,
|
||||
];
|
||||
|
||||
function findColumnIndex(headers: string[], patterns: RegExp[]): number {
|
||||
for (const pattern of patterns) {
|
||||
const idx = headers.findIndex((h) => pattern.test(h));
|
||||
if (idx >= 0) return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function autoMapColumns(headers: string[]): CsvColumnMapping {
|
||||
return {
|
||||
firstName: findColumnIndex(headers, NAME_PATTERNS),
|
||||
lastName: findColumnIndex(headers, LAST_NAME_PATTERNS),
|
||||
email: findColumnIndex(headers, EMAIL_PATTERNS),
|
||||
phone: findColumnIndex(headers, PHONE_PATTERNS),
|
||||
company: findColumnIndex(headers, COMPANY_PATTERNS),
|
||||
jobTitle: findColumnIndex(headers, JOB_TITLE_PATTERNS),
|
||||
address: findColumnIndex(headers, ADDRESS_PATTERNS),
|
||||
city: findColumnIndex(headers, CITY_PATTERNS),
|
||||
region: findColumnIndex(headers, REGION_PATTERNS),
|
||||
postcode: findColumnIndex(headers, POSTCODE_PATTERNS),
|
||||
country: findColumnIndex(headers, COUNTRY_PATTERNS),
|
||||
website: findColumnIndex(headers, WEBSITE_PATTERNS),
|
||||
note: findColumnIndex(headers, NOTE_PATTERNS),
|
||||
nickname: findColumnIndex(headers, NICKNAME_PATTERNS),
|
||||
};
|
||||
}
|
||||
|
||||
function getCol(row: string[], colIndex: number): string {
|
||||
if (colIndex < 0 || colIndex >= row.length) return "";
|
||||
return row[colIndex]?.trim() || "";
|
||||
}
|
||||
|
||||
export function mapRowToContact(
|
||||
row: string[],
|
||||
mapping: CsvColumnMapping,
|
||||
addressBookIds: Record<string, boolean>,
|
||||
): ContactCard | null {
|
||||
const id = `import-csv-${generateUUID()}`;
|
||||
|
||||
const firstName = getCol(row, mapping.firstName);
|
||||
const lastName = getCol(row, mapping.lastName);
|
||||
const email = getCol(row, mapping.email);
|
||||
const phone = getCol(row, mapping.phone);
|
||||
const company = getCol(row, mapping.company);
|
||||
const jobTitle = getCol(row, mapping.jobTitle);
|
||||
const address = getCol(row, mapping.address);
|
||||
const city = getCol(row, mapping.city);
|
||||
const region = getCol(row, mapping.region);
|
||||
const postcode = getCol(row, mapping.postcode);
|
||||
const country = getCol(row, mapping.country);
|
||||
const website = getCol(row, mapping.website);
|
||||
const note = getCol(row, mapping.note);
|
||||
const nickname = getCol(row, mapping.nickname);
|
||||
|
||||
if (!email && !firstName && !lastName) return null;
|
||||
|
||||
const components: NameComponent[] = [];
|
||||
if (firstName) components.push({ kind: "given", value: firstName });
|
||||
if (lastName) components.push({ kind: "surname", value: lastName });
|
||||
|
||||
const contact: ContactCard = {
|
||||
id,
|
||||
addressBookIds,
|
||||
};
|
||||
|
||||
if (components.length > 0) {
|
||||
contact.name = { components, isOrdered: true };
|
||||
} else if (email) {
|
||||
contact.name = { full: email.split("@")[0] };
|
||||
}
|
||||
|
||||
if (email) {
|
||||
contact.emails = {
|
||||
e0: { address: email },
|
||||
};
|
||||
}
|
||||
|
||||
if (phone) {
|
||||
contact.phones = {
|
||||
p0: { number: phone },
|
||||
};
|
||||
}
|
||||
|
||||
if (company) {
|
||||
contact.organizations = {
|
||||
o0: { name: company },
|
||||
};
|
||||
}
|
||||
|
||||
if (jobTitle) {
|
||||
contact.titles = {
|
||||
t0: { name: jobTitle, kind: "title" },
|
||||
};
|
||||
}
|
||||
|
||||
if (address || city || region || postcode || country) {
|
||||
contact.addresses = {
|
||||
a0: {
|
||||
street: address || undefined,
|
||||
locality: city || undefined,
|
||||
region: region || undefined,
|
||||
postcode: postcode || undefined,
|
||||
country: country || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (website) {
|
||||
contact.onlineServices = {
|
||||
u0: { uri: website },
|
||||
};
|
||||
}
|
||||
|
||||
if (note) {
|
||||
contact.notes = {
|
||||
n0: { note },
|
||||
};
|
||||
}
|
||||
|
||||
if (nickname) {
|
||||
contact.nicknames = {
|
||||
n0: { name: nickname },
|
||||
};
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
export function detectDuplicatesByEmail(
|
||||
existingContacts: ContactCard[],
|
||||
incoming: ContactCard[],
|
||||
): Map<number, string> {
|
||||
const dupes = new Map<number, string>();
|
||||
const existingEmails = new Map<string, string>();
|
||||
|
||||
for (const c of existingContacts) {
|
||||
if (c.emails) {
|
||||
for (const e of Object.values(c.emails)) {
|
||||
existingEmails.set(e.address.toLowerCase(), c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
incoming.forEach((card, idx) => {
|
||||
if (card.emails) {
|
||||
for (const e of Object.values(card.emails)) {
|
||||
const match = existingEmails.get(e.address.toLowerCase());
|
||||
if (match) {
|
||||
dupes.set(idx, match);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return dupes;
|
||||
}
|
||||
@@ -1037,6 +1037,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return [...this.data.fileNodes];
|
||||
}
|
||||
|
||||
async setMailboxShare(): Promise<void> { /* demo: no-op */ }
|
||||
|
||||
async setFileNodeShare(): Promise<void> { /* demo: no-op */ }
|
||||
|
||||
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { expandImportableEmails } from "@/lib/eml-import";
|
||||
|
||||
export type ConflictResolution = "skip" | "replace" | "copy";
|
||||
|
||||
export interface ImportProgress {
|
||||
total: number;
|
||||
processed: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
currentFile: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: Array<{ file: string; error: string }>;
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer): string {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
interface ParsedEml {
|
||||
messageId: string | null;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string;
|
||||
cc: string;
|
||||
date: string;
|
||||
bodyPlain: string;
|
||||
bodyHtml: string;
|
||||
raw: Blob;
|
||||
}
|
||||
|
||||
async function parseEml(file: Blob): Promise<ParsedEml> {
|
||||
const { default: PostalMime } = await import("postal-mime");
|
||||
const buffer = await file.arrayBuffer();
|
||||
const parsed = await PostalMime.parse(buffer);
|
||||
|
||||
return {
|
||||
messageId: (parsed.messageId || null) as string | null,
|
||||
subject: parsed.subject || "(No Subject)",
|
||||
from: typeof parsed.from === "object" && parsed.from?.address
|
||||
? `${parsed.from.name || ""} <${parsed.from.address}>`.trim()
|
||||
: String(parsed.from || ""),
|
||||
to: Array.isArray(parsed.to)
|
||||
? parsed.to.map((r: { address?: string; name?: string }) =>
|
||||
r.name ? `${r.name} <${r.address}>` : r.address || ""
|
||||
).join(", ")
|
||||
: "",
|
||||
cc: Array.isArray(parsed.cc)
|
||||
? parsed.cc.map((r: { address?: string; name?: string }) =>
|
||||
r.name ? `${r.name} <${r.address}>` : r.address || ""
|
||||
).join(", ")
|
||||
: "",
|
||||
date: parsed.date || "",
|
||||
bodyPlain: parsed.text || "",
|
||||
bodyHtml: parsed.html || "",
|
||||
raw: file,
|
||||
};
|
||||
}
|
||||
|
||||
async function findExistingMessageIds(
|
||||
client: IJMAPClient,
|
||||
messageIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
const existing = new Set<string>();
|
||||
const batchSize = 50;
|
||||
|
||||
for (let i = 0; i < messageIds.length; i += batchSize) {
|
||||
const batch = messageIds.slice(i, i + batchSize);
|
||||
try {
|
||||
const conditions = batch.map((id) => ({
|
||||
header: ["Message-ID", `<${id}>`] as [string, string],
|
||||
}));
|
||||
|
||||
const filter = conditions.length === 1
|
||||
? conditions[0]
|
||||
: { operator: "OR", conditions };
|
||||
|
||||
const { emails } = await client.advancedSearchEmails(filter, undefined, batchSize);
|
||||
for (const email of emails) {
|
||||
if (email.messageId && batch.includes(email.messageId)) {
|
||||
existing.add(email.messageId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort dedup lookup
|
||||
}
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
function generateRfc822FromParsed(eml: ParsedEml): Blob {
|
||||
const lines: string[] = [];
|
||||
lines.push(`From: ${eml.from}`);
|
||||
if (eml.to) lines.push(`To: ${eml.to}`);
|
||||
if (eml.cc) lines.push(`Cc: ${eml.cc}`);
|
||||
if (eml.messageId) lines.push(`Message-ID: <${eml.messageId}>`);
|
||||
lines.push(`Date: ${eml.date || new Date().toUTCString()}`);
|
||||
lines.push(`Subject: ${eml.subject}`);
|
||||
lines.push("MIME-Version: 1.0");
|
||||
|
||||
if (eml.bodyHtml) {
|
||||
const boundary = `----=_Boundary_${Date.now().toString(36)}`;
|
||||
lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
||||
lines.push("");
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push("Content-Type: text/plain; charset=utf-8");
|
||||
lines.push("Content-Transfer-Encoding: quoted-printable");
|
||||
lines.push("");
|
||||
lines.push(eml.bodyPlain);
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push("Content-Type: text/html; charset=utf-8");
|
||||
lines.push("Content-Transfer-Encoding: quoted-printable");
|
||||
lines.push("");
|
||||
lines.push(eml.bodyHtml);
|
||||
lines.push(`--${boundary}--`);
|
||||
} else {
|
||||
lines.push("Content-Type: text/plain; charset=utf-8");
|
||||
lines.push("Content-Transfer-Encoding: quoted-printable");
|
||||
lines.push("");
|
||||
lines.push(eml.bodyPlain);
|
||||
}
|
||||
|
||||
return new Blob([lines.join("\r\n")], { type: "message/rfc822" });
|
||||
}
|
||||
|
||||
async function extractMessageIdFromEml(blob: Blob): Promise<string | null> {
|
||||
try {
|
||||
const text = await blob.text();
|
||||
const match = text.match(/^Message-ID:\s*(.+)$/im);
|
||||
if (match) {
|
||||
return match[1].trim().replace(/^<+/, "").replace(/>+$/, "");
|
||||
}
|
||||
} catch {
|
||||
// best-effort message-id extraction
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function importEmails({
|
||||
client,
|
||||
files,
|
||||
destinationMailboxId,
|
||||
conflictResolution,
|
||||
onProgress,
|
||||
signal,
|
||||
}: {
|
||||
client: IJMAPClient;
|
||||
files: File[];
|
||||
destinationMailboxId: string;
|
||||
conflictResolution: ConflictResolution;
|
||||
onProgress?: (progress: ImportProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportResult> {
|
||||
const result: ImportResult = { imported: 0, skipped: 0, failed: 0, errors: [] };
|
||||
const progress: ImportProgress = {
|
||||
total: 0,
|
||||
processed: 0,
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
currentFile: "",
|
||||
};
|
||||
|
||||
const importables = await expandImportableEmails(files);
|
||||
progress.total = importables.length;
|
||||
onProgress?.({ ...progress });
|
||||
|
||||
if (importables.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const duplicateCheck = conflictResolution !== "copy";
|
||||
let existingMessageIds: Set<string> | null = null;
|
||||
|
||||
if (duplicateCheck) {
|
||||
const messageIds: string[] = [];
|
||||
for (const item of importables) {
|
||||
if (signal?.aborted) break;
|
||||
const msgId = await extractMessageIdFromEml(item.blob);
|
||||
if (msgId) messageIds.push(msgId);
|
||||
}
|
||||
existingMessageIds = await findExistingMessageIds(client, messageIds);
|
||||
}
|
||||
|
||||
for (const item of importables) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
progress.currentFile = item.name;
|
||||
progress.processed++;
|
||||
onProgress?.({ ...progress });
|
||||
|
||||
try {
|
||||
const msgId = await extractMessageIdFromEml(item.blob);
|
||||
if (duplicateCheck && msgId && existingMessageIds?.has(msgId)) {
|
||||
if (conflictResolution === "skip") {
|
||||
progress.skipped++;
|
||||
result.skipped++;
|
||||
onProgress?.({ ...progress });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let emlBlob = item.blob;
|
||||
|
||||
try {
|
||||
const parsed = await parseEml(item.blob);
|
||||
emlBlob = generateRfc822FromParsed(parsed);
|
||||
} catch {
|
||||
// best-effort dedup lookup
|
||||
}
|
||||
|
||||
const file = new File([emlBlob], item.name, { type: "message/rfc822" });
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
|
||||
await client.importEmail(
|
||||
blobId,
|
||||
{ [destinationMailboxId]: true },
|
||||
{ "$seen": true },
|
||||
);
|
||||
|
||||
progress.imported++;
|
||||
result.imported++;
|
||||
} catch (err) {
|
||||
progress.failed++;
|
||||
result.failed++;
|
||||
result.errors.push({
|
||||
file: item.name,
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
onProgress?.({ ...progress });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+95
-1
@@ -13,6 +13,14 @@ function isZipName(name: string): boolean {
|
||||
return /\.zip$/i.test(name);
|
||||
}
|
||||
|
||||
function isTgzName(name: string): boolean {
|
||||
return /\.(tgz|tar\.gz)$/i.test(name);
|
||||
}
|
||||
|
||||
function isArchiveName(name: string): boolean {
|
||||
return isZipName(name) || isTgzName(name);
|
||||
}
|
||||
|
||||
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
@@ -30,6 +38,88 @@ async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function gunzip(buffer: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
try {
|
||||
const ds = new DecompressionStream("gzip");
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
|
||||
writer.write(new Uint8Array(buffer));
|
||||
writer.close();
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result.buffer;
|
||||
} catch {
|
||||
throw new Error("Failed to decompress gzip archive");
|
||||
}
|
||||
}
|
||||
|
||||
interface TarEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
function parseTar(buffer: ArrayBuffer): TarEntry[] {
|
||||
const entries: TarEntry[] = [];
|
||||
const view = new Uint8Array(buffer);
|
||||
let offset = 0;
|
||||
|
||||
while (offset + 512 <= view.byteLength) {
|
||||
const header = new Uint8Array(buffer, offset, 512);
|
||||
const name = new TextDecoder().decode(header.subarray(0, 100)).replace(/\0.*$/, "");
|
||||
const type = String.fromCharCode(header[156] || 0) || "0";
|
||||
|
||||
if (!name) break;
|
||||
|
||||
let sizeStr = "";
|
||||
for (let i = 124; i < 136; i++) {
|
||||
const ch = String.fromCharCode(header[i]);
|
||||
if (ch === "\0" || ch === " ") break;
|
||||
sizeStr += ch;
|
||||
}
|
||||
const size = parseInt(sizeStr || "0", 8);
|
||||
|
||||
offset += 512;
|
||||
|
||||
if (size > 0 && type === "0" && isEmlName(name)) {
|
||||
const data = buffer.slice(offset, offset + size);
|
||||
entries.push({
|
||||
name: name.split(/[\\/]/).pop() || name,
|
||||
type: "file",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function extractEmlsFromTgz(file: File): Promise<ImportableEmail[]> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const decompressed = await gunzip(buffer);
|
||||
const entries = parseTar(decompressed);
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
blob: new Blob([entry.data], { type: EMAIL_MIME }),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function expandImportableEmails(
|
||||
files: File[],
|
||||
): Promise<ImportableEmail[]> {
|
||||
@@ -39,10 +129,14 @@ export async function expandImportableEmails(
|
||||
out.push(...(await extractEmlsFromZip(file)));
|
||||
continue;
|
||||
}
|
||||
if (isTgzName(file.name) || file.type === "application/gzip" || file.type === "application/x-gtar") {
|
||||
out.push(...(await extractEmlsFromTgz(file)));
|
||||
continue;
|
||||
}
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
|
||||
out.push({ name: file.name, blob });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const EML_IMPORT_ACCEPT = ".eml,.zip,message/rfc822,application/zip";
|
||||
export const EML_IMPORT_ACCEPT = ".eml,.zip,.tgz,.tar.gz,message/rfc822,application/zip,application/gzip";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
@@ -315,6 +315,7 @@ export interface IJMAPClient {
|
||||
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
|
||||
setMailboxShare(mailboxId: string, principalId: string, rights: MailboxRights | null, targetAccountId?: string): Promise<void>;
|
||||
|
||||
// ── Accounts (primary + shared/group) ────────────────────────
|
||||
getSharedAccounts(): SharedAccount[];
|
||||
|
||||
+29
-1
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -4426,6 +4426,34 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add, update, or remove a principal's rights on a mailbox folder.
|
||||
* Pass `rights: null` to revoke access.
|
||||
*/
|
||||
async setMailboxShare(
|
||||
mailboxId: string,
|
||||
principalId: string,
|
||||
rights: MailboxRights | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || this.accountId;
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId,
|
||||
update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notUpdated?.[mailboxId]) {
|
||||
const err = result.notUpdated[mailboxId];
|
||||
throw new Error(err.description || "Failed to update mailbox share");
|
||||
}
|
||||
if (!result?.updated || !(mailboxId in result.updated)) {
|
||||
throw new Error("Server did not confirm the share update");
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPaginatedContacts(
|
||||
accountId: string,
|
||||
filter?: Record<string, unknown>,
|
||||
|
||||
+15
-11
@@ -181,6 +181,19 @@ export interface Attachment {
|
||||
disposition?: string;
|
||||
}
|
||||
|
||||
export interface MailboxRights {
|
||||
mayReadItems: boolean;
|
||||
mayAddItems: boolean;
|
||||
mayRemoveItems: boolean;
|
||||
maySetSeen: boolean;
|
||||
maySetKeywords: boolean;
|
||||
mayCreateChild: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
mayShare?: boolean;
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
id: string;
|
||||
originalId?: string; // Original JMAP ID (for shared mailboxes)
|
||||
@@ -192,22 +205,13 @@ export interface Mailbox {
|
||||
unreadEmails: number;
|
||||
totalThreads: number;
|
||||
unreadThreads: number;
|
||||
myRights: {
|
||||
mayReadItems: boolean;
|
||||
mayAddItems: boolean;
|
||||
mayRemoveItems: boolean;
|
||||
maySetSeen: boolean;
|
||||
maySetKeywords: boolean;
|
||||
mayCreateChild: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
};
|
||||
myRights: MailboxRights;
|
||||
isSubscribed: boolean;
|
||||
// Shared folder support
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
shareWith?: Record<string, MailboxRights> | null;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
|
||||
+25
-1
@@ -892,6 +892,8 @@
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Content & Senders",
|
||||
"about_data": "About & Data",
|
||||
"import": "Import",
|
||||
"sharing": "Sharing",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"tab_groups": {
|
||||
@@ -2057,6 +2059,7 @@
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"share_folder": "Share Folder...",
|
||||
"import_email": "Import .eml or .zip...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
@@ -2662,6 +2665,20 @@
|
||||
"no_participants": "No participants",
|
||||
"count": "{count, plural, one {# participant} other {# participants}}"
|
||||
},
|
||||
"freeBusy": {
|
||||
"title": "Availability",
|
||||
"check": "Check Availability",
|
||||
"hide": "Hide Availability",
|
||||
"loading": "Loading...",
|
||||
"no_participants": "Add participants to check availability.",
|
||||
"timezone": "Timezone",
|
||||
"free": "Free",
|
||||
"busy": "Busy",
|
||||
"tentative": "Tentative",
|
||||
"unavailable": "Out of office",
|
||||
"unknown": "No information",
|
||||
"click_to_select": "Click a free slot to select this time"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "Recurrence",
|
||||
"none": "Does not repeat",
|
||||
@@ -3012,7 +3029,14 @@
|
||||
"readWrite": "Read & write",
|
||||
"manager": "Manager",
|
||||
"custom": "Custom"
|
||||
}
|
||||
},
|
||||
"tab_shared_by_me": "Shared by me",
|
||||
"tab_shared_with_me": "Shared with me",
|
||||
"no_shares_by_me": "You haven't shared anything yet.",
|
||||
"no_shares_with_me": "No folders shared with you yet.",
|
||||
"shared_by": "Shared by",
|
||||
"accept": "Accept",
|
||||
"decline": "Decline"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Advanced Search",
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { create } from "zustand";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type {
|
||||
Principal,
|
||||
CalendarRights,
|
||||
AddressBookRights,
|
||||
FileNodeRights,
|
||||
MailboxRights,
|
||||
} from "@/lib/jmap/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
export type SharedResourceKind =
|
||||
| "mailbox"
|
||||
| "calendar"
|
||||
| "addressBook"
|
||||
| "file";
|
||||
|
||||
export interface SharedFolder {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
resourceKind: SharedResourceKind;
|
||||
principalId: string;
|
||||
principalName: string;
|
||||
principalEmail: string | null;
|
||||
role: string;
|
||||
direction: "byMe" | "withMe";
|
||||
pending: boolean;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
interface SharingState {
|
||||
sharedByMe: SharedFolder[];
|
||||
sharedWithMe: SharedFolder[];
|
||||
loading: boolean;
|
||||
principalsCache: Principal[];
|
||||
|
||||
loadPrincipals: (client: IJMAPClient) => Promise<Principal[]>;
|
||||
fetchShares: (client: IJMAPClient) => Promise<void>;
|
||||
shareFolder: (
|
||||
client: IJMAPClient,
|
||||
resourceId: string,
|
||||
resourceName: string,
|
||||
resourceKind: SharedResourceKind,
|
||||
principalId: string,
|
||||
role: string,
|
||||
message?: string,
|
||||
accountId?: string,
|
||||
) => Promise<void>;
|
||||
revokeShare: (
|
||||
client: IJMAPClient,
|
||||
resourceId: string,
|
||||
resourceKind: SharedResourceKind,
|
||||
principalId: string,
|
||||
accountId?: string,
|
||||
) => Promise<void>;
|
||||
changeRole: (
|
||||
client: IJMAPClient,
|
||||
resourceId: string,
|
||||
resourceKind: SharedResourceKind,
|
||||
principalId: string,
|
||||
role: string,
|
||||
accountId?: string,
|
||||
) => Promise<void>;
|
||||
acceptShare: (client: IJMAPClient, share: SharedFolder) => Promise<void>;
|
||||
declineShare: (client: IJMAPClient, share: SharedFolder) => Promise<void>;
|
||||
}
|
||||
|
||||
function roleLabel(kind: SharedResourceKind, role: string): string {
|
||||
if (kind === "mailbox") return MAILBOX_ROLE_LABELS[role] ?? role;
|
||||
return role;
|
||||
}
|
||||
|
||||
const MAILBOX_PRESETS: Record<string, MailboxRights> = {
|
||||
read: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: true,
|
||||
},
|
||||
manager: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
mayShare: true,
|
||||
},
|
||||
};
|
||||
|
||||
const MAILBOX_ROLE_LABELS: Record<string, string> = {
|
||||
read: "Viewer",
|
||||
readWrite: "Editor",
|
||||
manager: "Manager",
|
||||
};
|
||||
|
||||
const CALENDAR_PRESETS: Record<string, CalendarRights> = {
|
||||
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<string, 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,
|
||||
},
|
||||
};
|
||||
|
||||
const FILE_PRESETS: Record<string, FileNodeRights> = {
|
||||
read: {
|
||||
mayRead: true,
|
||||
mayAddChildren: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
mayModifyContent: false,
|
||||
mayShare: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: false,
|
||||
},
|
||||
manager: {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: true,
|
||||
},
|
||||
};
|
||||
|
||||
function resolveRights(
|
||||
kind: SharedResourceKind,
|
||||
role: string,
|
||||
): MailboxRights | CalendarRights | AddressBookRights | FileNodeRights {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return (
|
||||
MAILBOX_PRESETS[role] ?? MAILBOX_PRESETS.read
|
||||
);
|
||||
case "calendar":
|
||||
return (
|
||||
CALENDAR_PRESETS[role] ?? CALENDAR_PRESETS.read
|
||||
);
|
||||
case "addressBook":
|
||||
return (
|
||||
ADDRESS_BOOK_PRESETS[role] ?? ADDRESS_BOOK_PRESETS.read
|
||||
);
|
||||
case "file":
|
||||
return FILE_PRESETS[role] ?? FILE_PRESETS.read;
|
||||
}
|
||||
}
|
||||
|
||||
export const useSharingStore = create<SharingState>((set, get) => ({
|
||||
sharedByMe: [],
|
||||
sharedWithMe: [],
|
||||
loading: false,
|
||||
principalsCache: [],
|
||||
|
||||
async loadPrincipals(client) {
|
||||
const cached = get().principalsCache;
|
||||
if (cached.length > 0) return cached;
|
||||
const principals = await client.getPrincipals();
|
||||
set({ principalsCache: principals });
|
||||
return principals;
|
||||
},
|
||||
|
||||
async fetchShares(client) {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const principals = await client.getPrincipals();
|
||||
const principalMap = new Map<string, Principal>();
|
||||
for (const p of principals) principalMap.set(p.id, p);
|
||||
|
||||
const byMe: SharedFolder[] = [];
|
||||
const withMe: SharedFolder[] = [];
|
||||
|
||||
try {
|
||||
const mailboxes = await client.getAllMailboxes();
|
||||
for (const mb of mailboxes) {
|
||||
const shares = mb.shareWith;
|
||||
if (shares && Object.keys(shares).length > 0) {
|
||||
for (const [principalId, rights] of Object.entries(shares)) {
|
||||
const p = principalMap.get(principalId);
|
||||
byMe.push({
|
||||
id: `mb-${mb.id}-${principalId}`,
|
||||
resourceId: mb.id,
|
||||
resourceName: mb.name,
|
||||
resourceKind: "mailbox",
|
||||
principalId,
|
||||
principalName: p?.name ?? principalId,
|
||||
principalEmail: p?.email ?? null,
|
||||
role: roleLabel("mailbox", detectMailboxPreset(rights)),
|
||||
direction: "byMe",
|
||||
pending: false,
|
||||
accountId: mb.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* mailboxes may not be available */
|
||||
}
|
||||
|
||||
try {
|
||||
if (client.supportsCalendars()) {
|
||||
const calendars = await client.getAllCalendars();
|
||||
for (const cal of calendars) {
|
||||
const shares = cal.shareWith;
|
||||
if (shares && Object.keys(shares).length > 0) {
|
||||
for (const [principalId, rights] of Object.entries(shares)) {
|
||||
const p = principalMap.get(principalId);
|
||||
byMe.push({
|
||||
id: `cal-${cal.id}-${principalId}`,
|
||||
resourceId: cal.id,
|
||||
resourceName: cal.name,
|
||||
resourceKind: "calendar",
|
||||
principalId,
|
||||
principalName: p?.name ?? principalId,
|
||||
principalEmail: p?.email ?? null,
|
||||
role: roleLabel("calendar", detectCalendarPreset(rights)),
|
||||
direction: "byMe",
|
||||
pending: false,
|
||||
accountId: cal.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* calendars may not be available */
|
||||
}
|
||||
|
||||
try {
|
||||
if (client.supportsContacts()) {
|
||||
const books = await client.getAllAddressBooks();
|
||||
for (const book of books) {
|
||||
const shares = book.shareWith;
|
||||
if (shares && Object.keys(shares).length > 0) {
|
||||
for (const [principalId, rights] of Object.entries(shares)) {
|
||||
const p = principalMap.get(principalId);
|
||||
byMe.push({
|
||||
id: `ab-${book.id}-${principalId}`,
|
||||
resourceId: book.id,
|
||||
resourceName: book.name,
|
||||
resourceKind: "addressBook",
|
||||
principalId,
|
||||
principalName: p?.name ?? principalId,
|
||||
principalEmail: p?.email ?? null,
|
||||
role: roleLabel(
|
||||
"addressBook",
|
||||
detectAddressBookPreset(rights),
|
||||
),
|
||||
direction: "byMe",
|
||||
pending: false,
|
||||
accountId: book.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* address books may not be available */
|
||||
}
|
||||
|
||||
set({ sharedByMe: byMe, sharedWithMe: withMe, loading: false, principalsCache: principals });
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
async shareFolder(
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
principalId,
|
||||
role,
|
||||
_message,
|
||||
accountId,
|
||||
) {
|
||||
const rights = resolveRights(resourceKind, role);
|
||||
await applyShare(client, resourceKind, resourceId, principalId, rights, accountId);
|
||||
|
||||
const princ = get().principalsCache.find((p) => p.id === principalId);
|
||||
const entry: SharedFolder = {
|
||||
id: `${resourceKind}-${resourceId}-${principalId}`,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
principalId,
|
||||
principalName: princ?.name ?? principalId,
|
||||
principalEmail: princ?.email ?? null,
|
||||
role,
|
||||
direction: "byMe",
|
||||
pending: false,
|
||||
accountId,
|
||||
};
|
||||
|
||||
set((s) => ({
|
||||
sharedByMe: [
|
||||
...s.sharedByMe.filter(
|
||||
(f) =>
|
||||
!(
|
||||
f.resourceId === resourceId &&
|
||||
f.principalId === principalId &&
|
||||
f.resourceKind === resourceKind
|
||||
),
|
||||
),
|
||||
entry,
|
||||
],
|
||||
}));
|
||||
toast.success(`Shared "${resourceName}"`);
|
||||
},
|
||||
|
||||
async revokeShare(client, resourceId, resourceKind, principalId, accountId) {
|
||||
await applyShare(client, resourceKind, resourceId, principalId, null, accountId);
|
||||
|
||||
set((s) => ({
|
||||
sharedByMe: s.sharedByMe.filter(
|
||||
(f) =>
|
||||
!(
|
||||
f.resourceId === resourceId &&
|
||||
f.principalId === principalId &&
|
||||
f.resourceKind === resourceKind
|
||||
),
|
||||
),
|
||||
sharedWithMe: s.sharedWithMe.filter(
|
||||
(f) =>
|
||||
!(
|
||||
f.resourceId === resourceId &&
|
||||
f.principalId === principalId &&
|
||||
f.resourceKind === resourceKind
|
||||
),
|
||||
),
|
||||
}));
|
||||
toast.success("Access revoked");
|
||||
},
|
||||
|
||||
async changeRole(
|
||||
client,
|
||||
resourceId,
|
||||
resourceKind,
|
||||
principalId,
|
||||
role,
|
||||
accountId,
|
||||
) {
|
||||
const rights = resolveRights(resourceKind, role);
|
||||
await applyShare(client, resourceKind, resourceId, principalId, rights, accountId);
|
||||
|
||||
set((s) => ({
|
||||
sharedByMe: s.sharedByMe.map((f) =>
|
||||
f.resourceId === resourceId &&
|
||||
f.principalId === principalId &&
|
||||
f.resourceKind === resourceKind
|
||||
? { ...f, role }
|
||||
: f,
|
||||
),
|
||||
}));
|
||||
toast.success("Role updated");
|
||||
},
|
||||
|
||||
async acceptShare(_client, share) {
|
||||
set((s) => ({
|
||||
sharedWithMe: s.sharedWithMe.map((f) =>
|
||||
f.id === share.id ? { ...f, pending: false } : f,
|
||||
),
|
||||
}));
|
||||
toast.success(`Accepted share: ${share.resourceName}`);
|
||||
},
|
||||
|
||||
async declineShare(_client, share) {
|
||||
set((s) => ({
|
||||
sharedWithMe: s.sharedWithMe.filter((f) => f.id !== share.id),
|
||||
}));
|
||||
toast.success(`Declined share: ${share.resourceName}`);
|
||||
},
|
||||
}));
|
||||
|
||||
async function applyShare(
|
||||
client: IJMAPClient,
|
||||
kind: SharedResourceKind,
|
||||
resourceId: string,
|
||||
principalId: string,
|
||||
rights:
|
||||
| MailboxRights
|
||||
| CalendarRights
|
||||
| AddressBookRights
|
||||
| FileNodeRights
|
||||
| null,
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
await client.setMailboxShare(
|
||||
resourceId,
|
||||
principalId,
|
||||
rights as MailboxRights | null,
|
||||
accountId,
|
||||
);
|
||||
break;
|
||||
case "calendar":
|
||||
await client.setCalendarShare(
|
||||
resourceId,
|
||||
principalId,
|
||||
rights as CalendarRights | null,
|
||||
accountId,
|
||||
);
|
||||
break;
|
||||
case "addressBook":
|
||||
await client.setAddressBookShare(
|
||||
resourceId,
|
||||
principalId,
|
||||
rights as AddressBookRights | null,
|
||||
accountId,
|
||||
);
|
||||
break;
|
||||
case "file":
|
||||
await client.setFileNodeShare(
|
||||
resourceId,
|
||||
principalId,
|
||||
rights as FileNodeRights | null,
|
||||
accountId,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function detectMailboxPreset(r: MailboxRights): string {
|
||||
for (const [name, preset] of Object.entries(MAILBOX_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof MailboxRights)[];
|
||||
if (
|
||||
keys.every(
|
||||
(k) =>
|
||||
(preset[k] ?? false) === (r[k as keyof MailboxRights] ?? false),
|
||||
)
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function detectCalendarPreset(r: CalendarRights): string {
|
||||
for (const [name, preset] of Object.entries(CALENDAR_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof CalendarRights)[];
|
||||
if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof CalendarRights] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function detectAddressBookPreset(r: AddressBookRights): string {
|
||||
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
||||
if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof AddressBookRights] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
Reference in New Issue
Block a user