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 }