const express = require('express'); const axios = require('axios'); const app = express(); const PORT = process.env.PORT || 3000; const NETSIDE_CALENDAR_URL = 'https://serv1.netside-planning.com/mb/peadpbaetb75b/79/dha2bu2wxmuewj7afeqh/en/nspl.ics'; function fixICSForHomeAssistant(icsContent) { let lines = icsContent.split(/\r?\n/); let fixed = []; let inTimezone = false; let timezoneMap = new Map(); let currentTimezone = null; for (let i = 0; i < lines.length; i++) { let line = lines[i].trim(); if (line === 'BEGIN:VTIMEZONE') { inTimezone = true; currentTimezone = { tzid: null, hasStandard: false, hasDaylight: false }; continue; } if (line === 'END:VTIMEZONE') { inTimezone = false; if (currentTimezone && currentTimezone.tzid) { timezoneMap.set(currentTimezone.tzid, currentTimezone); } currentTimezone = null; continue; } if (inTimezone && currentTimezone) { if (line.startsWith('TZID:')) { currentTimezone.tzid = line.substring(5); } else if (line === 'BEGIN:STANDARD') { currentTimezone.hasStandard = true; } else if (line === 'BEGIN:DAYLIGHT') { currentTimezone.hasDaylight = true; } } } console.log(`Timezones détectées: ${Array.from(timezoneMap.keys()).join(', ')}`); const utcTimezone = [ 'BEGIN:VTIMEZONE', 'TZID:UTC', 'BEGIN:STANDARD', 'DTSTART:19700101T000000', 'TZOFFSETFROM:+0000', 'TZOFFSETTO:+0000', 'TZNAME:UTC', 'END:STANDARD', 'END:VTIMEZONE' ]; inTimezone = false; let inCalendar = false; let hasValidTimezone = false; for (let i = 0; i < lines.length; i++) { let line = lines[i].trim(); if (line === 'BEGIN:VCALENDAR') { inCalendar = true; fixed.push(line); continue; } if (inCalendar && !hasValidTimezone && (line.startsWith('VERSION:') || line.startsWith('PRODID:'))) { fixed.push(line); if (line.startsWith('PRODID:')) { fixed.push(...utcTimezone); hasValidTimezone = true; } continue; } if (line === 'BEGIN:VTIMEZONE') { inTimezone = true; continue; } if (line === 'END:VTIMEZONE') { inTimezone = false; continue; } if (inTimezone) { continue; } if (line.startsWith('DTSTART') || line.startsWith('DTEND')) { if (line.includes('TZID=')) { const tzidMatch = line.match(/TZID=([^;:]+)/); if (tzidMatch) { const originalTzid = tzidMatch[1]; line = line.replace(/;TZID=[^;:]+/, ''); console.log(`Supprimé TZID ${originalTzid} pour événement multi-jours`); } } const dateValueMatch = line.match(/:(\d{8})(?:T(\d{6})Z?)?$/); if (dateValueMatch) { const dateOnly = dateValueMatch[1]; const timeOnly = dateValueMatch[2]; if (timeOnly) { if (!line.endsWith('Z')) { line = line.replace(/:(\d{8}T\d{6})$/, ':$1Z'); } } else { line = line.replace(/:(\d{8})$/, ';VALUE=DATE:$1'); } } } if (line.includes('TZID=')) { const tzidMatch = line.match(/TZID=([^;:]+)/); if (tzidMatch) { const originalTzid = tzidMatch[1]; if (line.includes('DTSTART') || line.includes('DTEND') || line.includes('DTSTAMP')) { line = line.replace(/TZID=[^;:]+/, 'TZID=UTC'); const valueMatch = line.match(/:(\d{8})$/); if (valueMatch) { line = line.replace(/:(\d{8})$/, ':$1T000000Z'); line = line.replace(/;TZID=UTC/, ''); } } else { line = line.replace(/;TZID=[^;:]+/, ''); } console.log(`Normalisé timezone ${originalTzid} vers UTC`); } } if ((line.startsWith('DTSTART:') || line.startsWith('DTEND:') || line.startsWith('DTSTAMP:')) && line.match(/:\d{8}T\d{6}$/) && !line.endsWith('Z')) { line = line + 'Z'; } if (line.startsWith('TZID:') && !inTimezone) { continue; } if (line === '') { continue; } if (line.startsWith('RRULE:')) { line = line.replace(/;+/g, ';').replace(/;$/, ''); } line = line.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); fixed.push(line); } const result = fixed.join('\n'); if (!result.includes('BEGIN:VCALENDAR')) { console.warn('VCALENDAR manquant, ajout...'); return 'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//ICS-Gateway//Gateway//EN\n' + utcTimezone.join('\n') + '\n' + result + '\nEND:VCALENDAR'; } if (!result.includes('END:VCALENDAR')) { console.warn('END:VCALENDAR manquant, ajout...'); return result + '\nEND:VCALENDAR'; } console.log('ICS normalisé avec succès'); return result; } app.get('/calendar.ics', async (req, res) => { try { console.log('Récupération du calendrier depuis Netside...'); const response = await axios.get(NETSIDE_CALENDAR_URL, { timeout: 10000, headers: { 'User-Agent': 'HomeAssistant-Gateway/1.0' } }); console.log('Calendrier récupéré, correction en cours...'); const fixedICS = fixICSForHomeAssistant(response.data); res.set({ 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'inline; filename=calendar.ics', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }); res.send(fixedICS); console.log('Calendrier corrigé envoyé avec succès'); } catch (error) { console.error('Erreur lors de la récupération du calendrier:', error.message); res.status(500).json({ error: 'Impossible de récupérer le calendrier', details: error.message }); } }); app.get('/health', (req, res) => { res.json({ status: 'OK', service: 'ICS Gateway for Home Assistant', timestamp: new Date().toISOString() }); }); app.get('/debug/original', async (req, res) => { try { const response = await axios.get(NETSIDE_CALENDAR_URL); res.set('Content-Type', 'text/plain'); res.send(response.data); } catch (error) { res.status(500).json({ error: error.message }); } }); app.listen(PORT, () => { console.log(`🚀 Serveur ICS Gateway démarré sur le port ${PORT}`); console.log(`📅 Calendrier disponible sur: http://localhost:${PORT}/calendar.ics`); console.log(`🔍 Health check: http://localhost:${PORT}/health`); console.log(`🐛 Debug (original): http://localhost:${PORT}/debug/original`); });