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,
|
SwatchBook,
|
||||||
Download,
|
Download,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Upload,
|
||||||
|
Share2,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -71,6 +73,8 @@ import { PluginsSettings } from '@/components/settings/plugins-settings';
|
|||||||
import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings';
|
import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings';
|
||||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||||
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
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 { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
@@ -115,6 +119,8 @@ type Tab =
|
|||||||
| 'about_data'
|
| 'about_data'
|
||||||
| 'themes'
|
| 'themes'
|
||||||
| 'plugins'
|
| 'plugins'
|
||||||
|
| 'import'
|
||||||
|
| 'sharing'
|
||||||
| 'ai_assistant'
|
| 'ai_assistant'
|
||||||
| 'debug';
|
| 'debug';
|
||||||
|
|
||||||
@@ -159,6 +165,8 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
about_data: Info,
|
about_data: Info,
|
||||||
themes: SwatchBook,
|
themes: SwatchBook,
|
||||||
plugins: Puzzle,
|
plugins: Puzzle,
|
||||||
|
import: Upload,
|
||||||
|
sharing: Share2,
|
||||||
ai_assistant: Sparkles,
|
ai_assistant: Sparkles,
|
||||||
debug: Bug,
|
debug: Bug,
|
||||||
};
|
};
|
||||||
@@ -243,6 +251,8 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
themes: [],
|
themes: [],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
ai_assistant: [],
|
ai_assistant: [],
|
||||||
|
import: ['settings.importer'],
|
||||||
|
sharing: ['sharing'],
|
||||||
debug: ['settings.advanced'],
|
debug: ['settings.advanced'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,6 +285,8 @@ const tabKeywords: Record<Tab, string> = {
|
|||||||
themes: 'custom theme css skin appearance',
|
themes: 'custom theme css skin appearance',
|
||||||
plugins: 'extensions addons',
|
plugins: 'extensions addons',
|
||||||
ai_assistant: 'assistant ask model llm ollama chatbot',
|
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',
|
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: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, 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: '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' },
|
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||||
|
|
||||||
// Appearance
|
// 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 }] : []),
|
...(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 }] : []),
|
...(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: '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 }] : []),
|
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
@@ -772,6 +786,8 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||||
|
{effectiveActiveTab === 'import' && <ImportSettings />}
|
||||||
|
{effectiveActiveTab === 'sharing' && <SharingSettings />}
|
||||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
{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 { useTranslations, useLocale } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||||
|
import { FreeBusyView } from "./free-busy-view";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -351,6 +352,7 @@ export function EventModal({
|
|||||||
.map(p => ({ name: p.name, email: p.email }));
|
.map(p => ({ name: p.name, email: p.email }));
|
||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
|
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
||||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||||
|
|
||||||
// Plugin transform: collect conflict warnings for the current event form.
|
// Plugin transform: collect conflict warnings for the current event form.
|
||||||
@@ -1074,6 +1076,44 @@ export function EventModal({
|
|||||||
onAdd={handleAddAttendee}
|
onAdd={handleAddAttendee}
|
||||||
onRemove={handleRemoveAttendee}
|
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) && (
|
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
{t("participants.status_summary", {
|
{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 { useState, useRef, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
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 { 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 {
|
interface ContactImportDialogProps {
|
||||||
existingContacts: ContactCard[];
|
existingContacts: ContactCard[];
|
||||||
|
addressBooks?: AddressBook[];
|
||||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactImportDialog({
|
export function ContactImportDialog({
|
||||||
existingContacts,
|
existingContacts,
|
||||||
|
addressBooks,
|
||||||
onImport,
|
onImport,
|
||||||
onClose,
|
onClose,
|
||||||
}: ContactImportDialogProps) {
|
}: ContactImportDialogProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [fileType, setFileType] = useState<FileType>(null);
|
||||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
|
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
|
||||||
@@ -29,41 +42,111 @@ export function ContactImportDialog({
|
|||||||
const [result, setResult] = useState<number | null>(null);
|
const [result, setResult] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | 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 handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(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"));
|
setError(t("import.file_too_large"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
if (name.endsWith(".csv") || file.type === "text/csv") {
|
||||||
const contacts = parseVCard(text);
|
setFileType("csv");
|
||||||
|
const text = await file.text();
|
||||||
|
const result = parseCSV(text);
|
||||||
|
|
||||||
if (contacts.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
setError(t("import.no_contacts"));
|
setError(t("import.no_contacts"));
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCsvData(result);
|
||||||
|
setMapping(autoMapColumns(result.headers));
|
||||||
|
setTargetBookId(defaultBookId);
|
||||||
|
} else {
|
||||||
|
setFileType("vcf");
|
||||||
|
const text = await file.text();
|
||||||
|
const contacts = parseVCard(text);
|
||||||
|
|
||||||
|
if (contacts.length === 0) {
|
||||||
|
setError(t("import.no_contacts"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dupes = detectDuplicates(existingContacts, contacts);
|
||||||
|
setParsed(contacts);
|
||||||
|
setDuplicates(dupes);
|
||||||
|
|
||||||
|
const initialSelected = new Set<number>();
|
||||||
|
contacts.forEach((_, idx) => {
|
||||||
|
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||||
|
});
|
||||||
|
setSelected(initialSelected);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
const dupes = detectDuplicates(existingContacts, contacts);
|
console.error("Failed to parse file:", err);
|
||||||
setParsed(contacts);
|
|
||||||
setDuplicates(dupes);
|
|
||||||
|
|
||||||
const initialSelected = new Set<number>();
|
|
||||||
contacts.forEach((_, idx) => {
|
|
||||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
|
||||||
});
|
|
||||||
setSelected(initialSelected);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse vCard:', error);
|
|
||||||
setError(t("import.parse_error"));
|
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 toggleSelect = (idx: number) => {
|
||||||
const next = new Set(selected);
|
const next = new Set(selected);
|
||||||
@@ -91,14 +174,160 @@ export function ContactImportDialog({
|
|||||||
try {
|
try {
|
||||||
const count = await onImport(toImport);
|
const count = await onImport(toImport);
|
||||||
setResult(count);
|
setResult(count);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Failed to import contacts:', error);
|
console.error("Failed to import contacts:", err);
|
||||||
setError(t("import.failed"));
|
setError(t("import.failed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
<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")}
|
{t("import.close")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : parsed.length === 0 ? (
|
) : fileType === null ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".vcf,.vcard"
|
accept={ALLOWED_ACCEPT}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
@@ -141,7 +370,7 @@ export function ContactImportDialog({
|
|||||||
>
|
>
|
||||||
<Upload className="w-8 h-8" />
|
<Upload className="w-8 h-8" />
|
||||||
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
|
<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>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -151,6 +380,10 @@ export function ContactImportDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : fileType === "csv" && csvData && !showPreview ? (
|
||||||
|
renderCsvMapping()
|
||||||
|
) : fileType === "csv" && csvData && showPreview ? (
|
||||||
|
renderCsvPreview()
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -217,7 +450,23 @@ export function ContactImportDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("import.selected", { count: selected.size })}
|
{t("import.selected", { count: selected.size })}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
FolderX,
|
FolderX,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Upload,
|
Upload,
|
||||||
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
@@ -86,6 +87,7 @@ interface MailboxContextMenuProps {
|
|||||||
onRenameFolder?: (mailboxId: string) => void;
|
onRenameFolder?: (mailboxId: string) => void;
|
||||||
onDeleteFolder?: (mailboxId: string) => void;
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
onImportEmail?: (mailboxId: string) => void;
|
onImportEmail?: (mailboxId: string) => void;
|
||||||
|
onShareFolder?: (mailboxId: string) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +107,7 @@ export function MailboxContextMenu({
|
|||||||
onRenameFolder,
|
onRenameFolder,
|
||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
onImportEmail,
|
onImportEmail,
|
||||||
|
onShareFolder,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: MailboxContextMenuProps) {
|
}: MailboxContextMenuProps) {
|
||||||
const t = useTranslations("mailbox_context_menu");
|
const t = useTranslations("mailbox_context_menu");
|
||||||
@@ -191,6 +194,12 @@ export function MailboxContextMenu({
|
|||||||
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||||
disabled={!onRenameFolder || !canRename}
|
disabled={!onRenameFolder || !canRename}
|
||||||
/>
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Share2}
|
||||||
|
label={t("share_folder")}
|
||||||
|
onClick={() => handleAction(() => onShareFolder?.(mailbox.id))}
|
||||||
|
disabled={!onShareFolder || mailbox.isShared}
|
||||||
|
/>
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ interface SidebarProps {
|
|||||||
onDeleteFolder?: (mailboxId: string) => void;
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
onImportEmail?: (mailboxId: string) => void;
|
onImportEmail?: (mailboxId: string) => void;
|
||||||
onRefreshMailboxes?: () => void;
|
onRefreshMailboxes?: () => void;
|
||||||
|
onShareFolder?: (mailboxId: string) => void;
|
||||||
scheduledTotal?: number;
|
scheduledTotal?: number;
|
||||||
showScheduledMailbox?: boolean;
|
showScheduledMailbox?: boolean;
|
||||||
/** True when the unified view spans multiple login accounts (cross-account).
|
/** True when the unified view spans multiple login accounts (cross-account).
|
||||||
@@ -779,6 +780,7 @@ export function Sidebar({
|
|||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
onImportEmail,
|
onImportEmail,
|
||||||
onRefreshMailboxes,
|
onRefreshMailboxes,
|
||||||
|
onShareFolder,
|
||||||
scheduledTotal = 0,
|
scheduledTotal = 0,
|
||||||
showScheduledMailbox = false,
|
showScheduledMailbox = false,
|
||||||
crossAccountActive = false,
|
crossAccountActive = false,
|
||||||
@@ -1452,6 +1454,7 @@ export function Sidebar({
|
|||||||
onRenameFolder={onRenameFolder}
|
onRenameFolder={onRenameFolder}
|
||||||
onDeleteFolder={onDeleteFolder}
|
onDeleteFolder={onDeleteFolder}
|
||||||
onImportEmail={onImportEmail}
|
onImportEmail={onImportEmail}
|
||||||
|
onShareFolder={onShareFolder}
|
||||||
onRefresh={onRefreshMailboxes}
|
onRefresh={onRefreshMailboxes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function ContactsSettings() {
|
|||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const {
|
const {
|
||||||
contacts,
|
contacts,
|
||||||
|
addressBooks,
|
||||||
supportsSync,
|
supportsSync,
|
||||||
importContacts,
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
@@ -46,6 +47,7 @@ export function ContactsSettings() {
|
|||||||
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
|
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
|
||||||
<ContactImportDialog
|
<ContactImportDialog
|
||||||
existingContacts={contacts}
|
existingContacts={contacts}
|
||||||
|
addressBooks={addressBooks}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onClose={() => setShowImport(false)}
|
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];
|
return [...this.data.fileNodes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setMailboxShare(): Promise<void> { /* demo: no-op */ }
|
||||||
|
|
||||||
async setFileNodeShare(): Promise<void> { /* demo: no-op */ }
|
async setFileNodeShare(): Promise<void> { /* demo: no-op */ }
|
||||||
|
|
||||||
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
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);
|
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[]> {
|
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
||||||
const { default: JSZip } = await import("jszip");
|
const { default: JSZip } = await import("jszip");
|
||||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||||
@@ -30,6 +38,88 @@ async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
|||||||
return out;
|
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(
|
export async function expandImportableEmails(
|
||||||
files: File[],
|
files: File[],
|
||||||
): Promise<ImportableEmail[]> {
|
): Promise<ImportableEmail[]> {
|
||||||
@@ -39,10 +129,14 @@ export async function expandImportableEmails(
|
|||||||
out.push(...(await extractEmlsFromZip(file)));
|
out.push(...(await extractEmlsFromZip(file)));
|
||||||
continue;
|
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 });
|
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
|
||||||
out.push({ name: file.name, blob });
|
out.push({ name: file.name, blob });
|
||||||
}
|
}
|
||||||
return out;
|
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";
|
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>;
|
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | 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>;
|
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) ────────────────────────
|
// ── Accounts (primary + shared/group) ────────────────────────
|
||||||
getSharedAccounts(): SharedAccount[];
|
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 { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
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(
|
private async fetchPaginatedContacts(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
filter?: Record<string, unknown>,
|
filter?: Record<string, unknown>,
|
||||||
|
|||||||
+15
-11
@@ -181,6 +181,19 @@ export interface Attachment {
|
|||||||
disposition?: string;
|
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 {
|
export interface Mailbox {
|
||||||
id: string;
|
id: string;
|
||||||
originalId?: string; // Original JMAP ID (for shared mailboxes)
|
originalId?: string; // Original JMAP ID (for shared mailboxes)
|
||||||
@@ -192,22 +205,13 @@ export interface Mailbox {
|
|||||||
unreadEmails: number;
|
unreadEmails: number;
|
||||||
totalThreads: number;
|
totalThreads: number;
|
||||||
unreadThreads: number;
|
unreadThreads: number;
|
||||||
myRights: {
|
myRights: MailboxRights;
|
||||||
mayReadItems: boolean;
|
|
||||||
mayAddItems: boolean;
|
|
||||||
mayRemoveItems: boolean;
|
|
||||||
maySetSeen: boolean;
|
|
||||||
maySetKeywords: boolean;
|
|
||||||
mayCreateChild: boolean;
|
|
||||||
mayRename: boolean;
|
|
||||||
mayDelete: boolean;
|
|
||||||
maySubmit: boolean;
|
|
||||||
};
|
|
||||||
isSubscribed: boolean;
|
isSubscribed: boolean;
|
||||||
// Shared folder support
|
// Shared folder support
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
|
shareWith?: Record<string, MailboxRights> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Thread {
|
export interface Thread {
|
||||||
|
|||||||
+25
-1
@@ -892,6 +892,8 @@
|
|||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"content_senders": "Content & Senders",
|
"content_senders": "Content & Senders",
|
||||||
"about_data": "About & Data",
|
"about_data": "About & Data",
|
||||||
|
"import": "Import",
|
||||||
|
"sharing": "Sharing",
|
||||||
"debug": "Debug"
|
"debug": "Debug"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
@@ -2057,6 +2059,7 @@
|
|||||||
"new_subfolder": "New subfolder...",
|
"new_subfolder": "New subfolder...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "New folder...",
|
||||||
"rename": "Rename...",
|
"rename": "Rename...",
|
||||||
|
"share_folder": "Share Folder...",
|
||||||
"import_email": "Import .eml or .zip...",
|
"import_email": "Import .eml or .zip...",
|
||||||
"empty_folder": "Empty folder",
|
"empty_folder": "Empty folder",
|
||||||
"empty_folder_generic": "Empty folder",
|
"empty_folder_generic": "Empty folder",
|
||||||
@@ -2662,6 +2665,20 @@
|
|||||||
"no_participants": "No participants",
|
"no_participants": "No participants",
|
||||||
"count": "{count, plural, one {# participant} other {# 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": {
|
"recurrence": {
|
||||||
"title": "Recurrence",
|
"title": "Recurrence",
|
||||||
"none": "Does not repeat",
|
"none": "Does not repeat",
|
||||||
@@ -3012,7 +3029,14 @@
|
|||||||
"readWrite": "Read & write",
|
"readWrite": "Read & write",
|
||||||
"manager": "Manager",
|
"manager": "Manager",
|
||||||
"custom": "Custom"
|
"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": {
|
"advanced_search": {
|
||||||
"title": "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