package watchlist import ( "encoding/json" "fmt" "os" "strings" "sync" "time" ) type Entry struct { Callsign string `json:"callsign"` LastSeen time.Time `json:"lastSeen"` LastSeenStr string `json:"lastSeenStr"` AddedAt time.Time `json:"addedAt"` SpotCount int `json:"spotCount"` IsContest bool `json:"isContest"` Notify bool `json:"notify"` // ActiveSpotIDs n'est pas sérialisé — reconstruit en mémoire activeSpotIDs map[int64]bool } type Watchlist struct { entries map[string]*Entry filePath string mu sync.RWMutex } func New(filePath string) *Watchlist { w := &Watchlist{ entries: make(map[string]*Entry), filePath: filePath, } w.load() return w } func (w *Watchlist) load() { w.mu.Lock() defer w.mu.Unlock() data, err := os.ReadFile(w.filePath) if err != nil { return } var entries []Entry if err := json.Unmarshal(data, &entries); err != nil { return } for i := range entries { e := &entries[i] e.activeSpotIDs = make(map[int64]bool) if e.LastSeen.IsZero() { e.LastSeenStr = "Never" } else { e.LastSeenStr = FormatLastSeen(e.LastSeen) } w.entries[e.Callsign] = e } } func (w *Watchlist) save() error { entries := make([]Entry, 0, len(w.entries)) for _, e := range w.entries { entries = append(entries, *e) } data, err := json.MarshalIndent(entries, "", " ") if err != nil { return err } return os.WriteFile(w.filePath, data, 0644) } func (w *Watchlist) Add(callsign string) error { return w.add(callsign, false) } func (w *Watchlist) AddContest(callsign string) error { return w.add(callsign, true) } func (w *Watchlist) add(callsign string, isContest bool) error { w.mu.Lock() defer w.mu.Unlock() callsign = strings.ToUpper(strings.TrimSpace(callsign)) if callsign == "" { return fmt.Errorf("callsign cannot be empty") } if _, exists := w.entries[callsign]; exists { return fmt.Errorf("callsign already in watchlist") } w.entries[callsign] = &Entry{ Callsign: callsign, AddedAt: time.Now(), LastSeenStr: "Never", activeSpotIDs: make(map[int64]bool), IsContest: isContest, } return w.save() } func (w *Watchlist) Remove(callsign string) error { w.mu.Lock() defer w.mu.Unlock() callsign = strings.ToUpper(strings.TrimSpace(callsign)) if _, exists := w.entries[callsign]; !exists { return fmt.Errorf("callsign not in watchlist") } delete(w.entries, callsign) return w.save() } func (w *Watchlist) SetNotify(callsign string, notify bool) error { w.mu.Lock() defer w.mu.Unlock() callsign = strings.ToUpper(strings.TrimSpace(callsign)) e, exists := w.entries[callsign] if !exists { return fmt.Errorf("callsign not found") } e.Notify = notify return w.save() } func (w *Watchlist) Matches(callsign string) bool { w.mu.RLock() defer w.mu.RUnlock() _, ok := w.entries[strings.ToUpper(callsign)] return ok } func (w *Watchlist) GetEntry(callsign string) *Entry { w.mu.RLock() defer w.mu.RUnlock() e, ok := w.entries[strings.ToUpper(callsign)] if !ok { return nil } copy := *e return © } func (w *Watchlist) GetAll() []Entry { w.mu.RLock() defer w.mu.RUnlock() entries := make([]Entry, 0, len(w.entries)) for _, e := range w.entries { cp := *e if !cp.LastSeen.IsZero() { cp.LastSeenStr = FormatLastSeen(cp.LastSeen) } entries = append(entries, cp) } return entries } func (w *Watchlist) GetAllCallsigns() []string { w.mu.RLock() defer w.mu.RUnlock() callsigns := make([]string, 0, len(w.entries)) for cs := range w.entries { callsigns = append(callsigns, cs) } return callsigns } func (w *Watchlist) MarkSeen(callsign string) { w.mu.Lock() defer w.mu.Unlock() e, ok := w.entries[strings.ToUpper(callsign)] if !ok { return } e.LastSeen = time.Now() e.LastSeenStr = FormatLastSeen(e.LastSeen) e.SpotCount++ } func (w *Watchlist) AddActiveSpot(callsign string, spotID int64) { w.mu.Lock() defer w.mu.Unlock() e, ok := w.entries[strings.ToUpper(callsign)] if !ok { return } if e.activeSpotIDs == nil { e.activeSpotIDs = make(map[int64]bool) } e.activeSpotIDs[spotID] = true } func (w *Watchlist) RemoveActiveSpot(spotID int64) { w.mu.Lock() defer w.mu.Unlock() for _, e := range w.entries { delete(e.activeSpotIDs, spotID) } } func (w *Watchlist) GetAllWithActiveStatus() []map[string]interface{} { w.mu.RLock() defer w.mu.RUnlock() result := make([]map[string]interface{}, 0, len(w.entries)) for _, e := range w.entries { lastSeenStr := "Never" if !e.LastSeen.IsZero() { lastSeenStr = FormatLastSeen(e.LastSeen) } result = append(result, map[string]interface{}{ "callsign": e.Callsign, "lastSeen": e.LastSeen, "lastSeenStr": lastSeenStr, "addedAt": e.AddedAt, "spotCount": e.SpotCount, "notify": e.Notify, "isContest": e.IsContest, "hasActiveSpots": len(e.activeSpotIDs) > 0, "activeCount": len(e.activeSpotIDs), }) } return result } func FormatLastSeen(t time.Time) string { if t.IsZero() { return "Never" } d := time.Since(t) switch { case d < time.Minute: return "Just now" case d < time.Hour: m := int(d.Minutes()) if m == 1 { return "1 minute ago" } return fmt.Sprintf("%d minutes ago", m) case d < 24*time.Hour: h := int(d.Hours()) if h == 1 { return "1 hour ago" } return fmt.Sprintf("%d hours ago", h) default: days := int(d.Hours() / 24) if days == 1 { return "1 day ago" } return fmt.Sprintf("%d days ago", days) } }