first
This commit is contained in:
40
.dockerignore
Normal file
40
.dockerignore
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Dépendances
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Fichiers de développement
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Fichiers temporaires
|
||||||
|
.tmp
|
||||||
|
.temp
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*
|
||||||
|
.dockerignore
|
||||||
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Environment variables
|
||||||
|
#.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules
|
||||||
|
jspm_packages
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
[Uu]ploads/
|
||||||
|
dist/
|
||||||
|
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodeuser -u 1001
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm ci --only=production && \
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
COPY index.js ./
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
LABEL maintainer="joris.bertomeu@gmail.com" \
|
||||||
|
version="1.0" \
|
||||||
|
description="Gateway ICS pour Home Assistant"
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "index.js"]
|
||||||
BIN
ics-gw.tar
Normal file
BIN
ics-gw.tar
Normal file
Binary file not shown.
237
index.js
Normal file
237
index.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
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`);
|
||||||
|
});
|
||||||
1371
package-lock.json
generated
Normal file
1371
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "ics-gateway-homeassistant",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Gateway Node.js pour corriger les fichiers ICS et les rendre compatibles avec Home Assistant",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "nodemon index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"express": "^5.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ics",
|
||||||
|
"calendar",
|
||||||
|
"homeassistant",
|
||||||
|
"gateway"
|
||||||
|
],
|
||||||
|
"author": "Votre nom",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user