Files
FlexDXCluster2/internal/watchlist/watchlist.go
2026-03-17 20:20:23 +01:00

261 lines
5.4 KiB
Go

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 &copy
}
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)
}
}