318 lines
9.6 KiB
TypeScript
318 lines
9.6 KiB
TypeScript
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(/</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" : ""}`
|
||
}
|