ultisuite-client/lib/calendar-invitation.ts
2026-05-15 17:40:17 +02:00

301 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<VideoConferenceProvider, string> = {
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<string, string> } {
const semi = left.indexOf(";")
if (semi === -1) {
return { name: left.toUpperCase(), params: {} }
}
const name = left.slice(0, semi).toUpperCase()
const params: Record<string, string> = {}
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<string, string>
): 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(/<br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|tr)>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
)
}
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 =
new Intl.DateTimeFormat(undefined, { timeZone: tz, timeZoneName: "short" })
.formatToParts(start)
.find((p) => p.type === "timeZoneName")?.value ?? ""
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" : ""}`
}