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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user