205 lines
5.6 KiB
Go
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
|
|
}
|