Files
RentalManager/internal/ical/ical.go
2026-04-11 12:12:07 +02:00

205 lines
5.6 KiB
Go

package ical
import (
"bufio"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/f4bpo/rental-manager/internal/calendar"
"github.com/f4bpo/rental-manager/internal/property"
)
type Service struct {
calStore *calendar.Store
propStore *property.Store
}
func NewService(calStore *calendar.Store, propStore *property.Store) *Service {
return &Service{calStore: calStore, propStore: propStore}
}
// StartSync lance une goroutine qui synchronise toutes les heures
func (s *Service) StartSync() {
go func() {
s.syncAll()
ticker := time.NewTicker(1 * time.Hour)
for range ticker.C {
s.syncAll()
}
}()
}
func (s *Service) SyncProperty(prop *property.Property) (int, error) {
if prop.IcalURL == "" {
return 0, fmt.Errorf("pas d'URL iCal pour %s", prop.Name)
}
req, err := http.NewRequest("GET", prop.IcalURL, nil)
if err != nil {
return 0, fmt.Errorf("erreur création requête iCal: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; RentalManager/1.0)")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("erreur fetch iCal: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("iCal HTTP %d pour %s", resp.StatusCode, prop.IcalURL)
}
events, err := parseIcal(resp, prop.ID)
if err != nil {
return 0, err
}
// Supprimer les anciens événements iCal pour repartir d'une base propre
// (gère aussi les réservations annulées et les doublons)
if err := s.calStore.DeleteIcalEvents(prop.ID); err != nil {
return 0, fmt.Errorf("erreur nettoyage calendrier: %w", err)
}
imported := 0
for _, e := range events {
if err := s.calStore.InsertFromIcal(&e); err != nil {
log.Printf("ical: erreur insert event %s: %v", e.IcalUID, err)
continue
}
imported++
}
return imported, nil
}
type SyncResult struct {
Property string `json:"property"`
Imported int `json:"imported"`
Error string `json:"error,omitempty"`
}
func (s *Service) SyncAll() []SyncResult {
props, err := s.propStore.ListWithIcal()
if err != nil {
log.Printf("ical sync: erreur liste propriétés: %v", err)
return []SyncResult{{Property: "system", Error: err.Error()}}
}
log.Printf("ical sync: %d propriété(s) avec URL iCal trouvée(s)", len(props))
if len(props) == 0 {
return []SyncResult{{Property: "system", Error: "aucune propriété avec URL iCal configurée"}}
}
var results []SyncResult
for _, p := range props {
n, err := s.SyncProperty(&p)
if err != nil {
log.Printf("ical sync [%s]: erreur: %v", p.Name, err)
s.calStore.LogSync(p.ID, "error", 0, err.Error())
results = append(results, SyncResult{Property: p.Name, Error: err.Error()})
} else {
log.Printf("ical sync [%s]: %d événements importés", p.Name, n)
s.calStore.LogSync(p.ID, "ok", n, "")
results = append(results, SyncResult{Property: p.Name, Imported: n})
}
}
return results
}
func (s *Service) syncAll() { s.SyncAll() }
// parseIcal parse un flux iCal et retourne les événements VEVENT
func parseIcal(resp *http.Response, propertyID string) ([]calendar.Event, error) {
// Unfold iCal lines (RFC 5545: continuation lines start with space or tab)
lines := unfoldIcal(resp)
var events []calendar.Event
var current *calendar.Event
for _, line := range lines {
line = strings.TrimRight(line, "\r\n")
if line == "" {
continue
}
switch {
case line == "BEGIN:VEVENT":
current = &calendar.Event{PropertyID: propertyID, Source: "airbnb"}
case line == "END:VEVENT" && current != nil:
if current.IcalUID != "" && current.StartDate != "" && current.EndDate != "" {
events = append(events, *current)
} else {
log.Printf("ical: VEVENT ignoré (uid=%q start=%q end=%q)", current.IcalUID, current.StartDate, current.EndDate)
}
current = nil
case current != nil:
key, value, found := strings.Cut(line, ":")
if !found {
continue
}
// Gérer les paramètres du type DTSTART;VALUE=DATE:20240601
baseKey := strings.SplitN(key, ";", 2)[0]
switch baseKey {
case "UID":
current.IcalUID = value
case "SUMMARY":
current.Title = unescapeIcal(value)
case "DTSTART":
current.StartDate = parseIcalDate(value)
case "DTEND":
current.EndDate = parseIcalDate(value)
case "DESCRIPTION":
current.Notes = unescapeIcal(value)
}
}
}
log.Printf("ical: %d événements parsés pour la propriété %s", len(events), propertyID)
return events, nil
}
// unfoldIcal joint les lignes repliées (RFC 5545 §3.1)
func unfoldIcal(resp *http.Response) []string {
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer
var lines []string
for scanner.Scan() {
text := scanner.Text()
if len(lines) > 0 && len(text) > 0 && (text[0] == ' ' || text[0] == '\t') {
// Continuation line: append to previous
lines[len(lines)-1] += strings.TrimLeft(text, " \t")
} else {
lines = append(lines, text)
}
}
return lines
}
func parseIcalDate(s string) string {
// Extraire uniquement la partie date (avant tout T, +, -)
// pour gérer : YYYYMMDD, YYYYMMDDTHHMMSSZ, YYYYMMDDTHHMMSS, YYYYMMDDTHHMMSS+HHMM, etc.
raw := strings.SplitN(s, "T", 2)[0] // prend "YYYYMMDD" avant le T éventuel
raw = strings.SplitN(raw, "+", 2)[0]
raw = strings.SplitN(raw, "Z", 2)[0]
if len(raw) == 8 {
return fmt.Sprintf("%s-%s-%s", raw[0:4], raw[4:6], raw[6:8])
}
// Déjà au format YYYY-MM-DD
if len(s) >= 10 && s[4] == '-' {
return s[:10]
}
return s
}
func unescapeIcal(s string) string {
s = strings.ReplaceAll(s, `\n`, "\n")
s = strings.ReplaceAll(s, `\,`, ",")
s = strings.ReplaceAll(s, `\;`, ";")
s = strings.ReplaceAll(s, `\\`, `\`)
return s
}