import { format, isThisYear, isToday, isTomorrow } from "date-fns" import { fr } from "date-fns/locale" import { toDate } from "date-fns-tz" export type VideoConferenceProvider = | "meet" | "teams" | "skype" | "zoom" | "whatsapp" | "signal" | "instagram" | "discord" | "slack" | "telegram" | "messenger" | "jitsi" | "phone" | "facetime" | "other" /** Icônes `logos:` (carrées quand dispo) — sous-ensemble dans `lib/iconify-logos-vc-subset.json` */ export const VIDEO_CONFERENCE_LOGOS: Record = { meet: "logos:google-meet", teams: "logos:microsoft-teams", skype: "logos:skype", zoom: "logos:zoom-icon", whatsapp: "logos:whatsapp-icon", signal: "logos:signal", instagram: "logos:instagram-icon", discord: "logos:discord-icon", slack: "logos:slack-icon", telegram: "logos:telegram", messenger: "logos:messenger", jitsi: "simple-icons:jitsi", phone: "logos:twilio-icon", facetime: "cbi:iosfacetime", other: "logos:webrtc", } /** @deprecated Utiliser `VIDEO_CONFERENCE_LOGOS` */ export const VIDEO_CONFERENCE_LIST_LOGOS = VIDEO_CONFERENCE_LOGOS export interface ParsedCalendarPerson { email?: string name?: string } export interface ParsedCalendarInvitation { summary: string start: Date end: Date organizer?: ParsedCalendarPerson attendees: ParsedCalendarPerson[] location?: string description?: string conferenceProvider: VideoConferenceProvider } function unescapeIcsValue(raw: string): string { return raw .replace(/\\n/gi, "\n") .replace(/\\,/g, ",") .replace(/\\;/g, ";") .replace(/\\\\/g, "\\") } function unfoldIcs(raw: string): string { return raw.replace(/\r\n/g, "\n").replace(/\n[\t ]/g, "") } function parseIcsParams(left: string): { name: string; params: Record } { const semi = left.indexOf(";") if (semi === -1) { return { name: left.toUpperCase(), params: {} } } const name = left.slice(0, semi).toUpperCase() const params: Record = {} for (const part of left.slice(semi + 1).split(";")) { const eq = part.indexOf("=") if (eq === -1) continue const k = part.slice(0, eq).toUpperCase() let v = part.slice(eq + 1) if ( (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ) { v = v.slice(1, -1) } params[k] = unescapeIcsValue(v) } return { name, params } } /** Parse `20260514T143000Z` or `20260514` + optional TZID */ function parseIcsDateTime( valueRaw: string, tzid?: string ): Date | null { const value = valueRaw.trim() const dateOnly = value.match(/^(\d{4})(\d{2})(\d{2})$/) if (dateOnly) { const y = Number(dateOnly[1]) const mo = Number(dateOnly[2]) const d = Number(dateOnly[3]) return new Date(y, mo - 1, d, 9, 0, 0, 0) } const dt = value.match( /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/ ) if (!dt) return null const [, ys, ms, ds, hs, mins, ss, z] = dt const isoLocal = `${ys}-${ms}-${ds}T${hs}:${mins}:${ss}` if (z) { return new Date(`${isoLocal}Z`) } if (tzid) { try { return toDate(isoLocal, { timeZone: tzid }) } catch { return new Date(isoLocal) } } return new Date(isoLocal) } function parseMailtoPerson( value: string, params: Record ): ParsedCalendarPerson { const m = value.match(/^MAILTO:(.+)$/i) const email = m?.[1]?.toLowerCase() const cn = params.CN return { email: email || undefined, name: cn || undefined, } } const CONF_SCAN_ORDER: { id: VideoConferenceProvider; patterns: RegExp[] }[] = [ { id: "meet", patterns: [/meet\.google\.com/i, /video\.google\.com/i] }, { id: "teams", patterns: [/teams\.microsoft\.com/i, /teams\.live\.com/i] }, { id: "zoom", patterns: [/zoom\.us/i, /\.zoom\.us/i] }, { id: "other", patterns: [/webex\.com/i, /ciscowebex\.com/i] }, { id: "skype", patterns: [/join\.skype\.com/i, /skype:\/\//i] }, { id: "jitsi", patterns: [/meet\.jit\.si/i, /8x8\.vc/i, /jitsi\.org/i] }, { id: "whatsapp", patterns: [/wa\.me/i, /whatsapp\.com\/call/i, /chat\.whatsapp\.com/i] }, { id: "signal", patterns: [/signal\.me/i, /signal\.group/i] }, { id: "instagram", patterns: [/instagram\.com/i] }, { id: "discord", patterns: [/discord\.gg/i, /discord\.com\/channels/i] }, { id: "slack", patterns: [/slack\.com\/call/i, /slack\.com\/huddle/i] }, { id: "telegram", patterns: [/t\.me\//i, /telegram\.me\//i] }, { id: "messenger", patterns: [/m\.me\//i, /messenger\.com/i] }, { id: "facetime", patterns: [/facetime:/i] }, { id: "phone", patterns: [/tel:/i] }, ] function detectConferenceProvider(blob: string): VideoConferenceProvider { const hay = blob.toLowerCase() for (const { id, patterns } of CONF_SCAN_ORDER) { for (const re of patterns) { if (re.test(hay)) { return id } } } return "other" } function extractVEventCalendar(ics: string): string | null { const u = unfoldIcs(ics) const start = u.search(/BEGIN:VEVENT/i) if (start === -1) return null const end = u.indexOf("END:VEVENT", start) if (end === -1) return null return u.slice(start, end + "END:VEVENT".length) } export function parseCalendarInvitationFromIcs( icsRaw: string ): ParsedCalendarInvitation | null { const block = extractVEventCalendar(icsRaw) if (!block) return null let summary = "Événement" let dtStart: { value: string; tzid?: string } | null = null let dtEnd: { value: string; tzid?: string } | null = null let organizer: ParsedCalendarPerson | undefined const attendees: ParsedCalendarPerson[] = [] let location: string | undefined let description: string | undefined let googleConf: string | undefined for (const line of block.split("\n")) { if (!line || line.startsWith("BEGIN:") || line.startsWith("END:")) continue const colon = line.indexOf(":") if (colon === -1) continue const left = line.slice(0, colon) const rawValue = line.slice(colon + 1) const { name, params } = parseIcsParams(left) const value = unescapeIcsValue(rawValue) if (name === "SUMMARY") summary = value || summary else if (name === "DTSTART" || name === "DTSTART;VALUE=DATE") { dtStart = { value, tzid: params.TZID } } else if (name === "DTEND" || name === "DTEND;VALUE=DATE") { dtEnd = { value, tzid: params.TZID } } else if (name === "ORGANIZER") { organizer = parseMailtoPerson(value, params) } else if (name === "ATTENDEE") { attendees.push(parseMailtoPerson(value, params)) } else if (name === "LOCATION") location = value else if (name === "DESCRIPTION") description = value else if (name.startsWith("X-GOOGLE-CONFERENCE")) googleConf = value } if (!dtStart) return null const start = parseIcsDateTime(dtStart.value, dtStart.tzid) if (!start || Number.isNaN(start.getTime())) return null let end: Date if (dtEnd) { const parsedEnd = parseIcsDateTime(dtEnd.value, dtEnd.tzid ?? dtStart.tzid) end = parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : new Date(start.getTime() + 60 * 60 * 1000) } else { end = new Date(start.getTime() + 60 * 60 * 1000) } const blob = [googleConf, location, description, summary].filter(Boolean).join("\n") const conferenceProvider = detectConferenceProvider(blob) return { summary, start, end, organizer, attendees, location, description, conferenceProvider, } } export function extractEmbeddedIcsFromHtml(html: string): string | null { const m = html.match(/BEGIN:VCALENDAR[\s\S]*?END:VCALENDAR/i) if (!m) return null return unfoldIcs( m[0] .replace(//gi, "\n") .replace(/<\/(p|div|tr)>/gi, "\n") .replace(/<[^>]+>/g, "") .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&") .replace(/"/g, '"') ) } /** Libellé UTC+X stable (évite GMT vs UTC entre Node et le navigateur). */ function formatStableUtcOffsetLabel(date: Date, timeZone: string): string { const offset = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "longOffset", }) .formatToParts(date) .find((p) => p.type === "timeZoneName")?.value ?? "" const normalized = offset.replace(/^GMT/i, "UTC") const m = normalized.match(/^UTC([+-])(\d{1,2})(?::(\d{2}))?$/i) if (!m) return normalized const sign = m[1] const hours = Number(m[2]) const minutes = m[3] ? Number(m[3]) : 0 if (minutes === 0) return `UTC${sign}${hours}` return `UTC${sign}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}` } export function formatInvitationTimeChip(start: Date, end: Date): string { const locale = fr const tz = Intl.DateTimeFormat().resolvedOptions().timeZone const timeFmt = "HH:mm" const startLabel = format(start, timeFmt, { locale }) const endLabel = format(end, timeFmt, { locale }) let day: string if (isToday(start)) day = "Aujourd'hui" else if (isTomorrow(start)) day = "Demain" else { day = isThisYear(start) ? format(start, "EEE d MMM", { locale }) : format(start, "EEE d MMM yyyy", { locale }) } const tzShort = formatStableUtcOffsetLabel(start, tz) const range = `${startLabel} – ${endLabel}` return tzShort ? `${day} • ${range} (${tzShort})` : `${day} • ${range}` } export function formatInvitationAttendeeLine( people: ParsedCalendarPerson[], maxShow = 3 ): string { const parts = people .map((p) => p.email || p.name) .filter(Boolean) as string[] const shown = parts.slice(0, maxShow) const rest = parts.length - shown.length if (rest <= 0) return shown.join(", ") return `${shown.join(", ")} et ${rest} autre${rest > 1 ? "s" : ""}` }