// Package model defines the persisted data structures for scientific-surfing. package model import ( "fmt" "path/filepath" "sort" ) // SubscriptionStatus mirrors Python's SubscriptionStatus(str, Enum). type SubscriptionStatus string const ( StatusActive SubscriptionStatus = "active" StatusInactive SubscriptionStatus = "inactive" ) // Subscription is a single clash RSS subscription entry. type Subscription struct { Name string `yaml:"name"` URL string `yaml:"url"` Status SubscriptionStatus `yaml:"status"` LastRefresh *Time `yaml:"last_refresh"` FileSize *int64 `yaml:"file_size"` StatusCode *int `yaml:"status_code"` // int64, not uint64: this Go implementation's own fnv64a hash (see // stableHash in the subscription package) fits either way, but the // original Python implementation's builtin hash() is a signed 64-bit // value and is frequently negative — existing subscriptions.yaml files // written by it must still parse. ContentHash *int64 `yaml:"content_hash"` LastError *string `yaml:"last_error"` } // NewSubscription creates a subscription defaulting to inactive, matching the // Pydantic model's field defaults. func NewSubscription(name, url string) *Subscription { return &Subscription{ Name: name, URL: url, Status: StatusInactive, } } // GetFilePath returns the on-disk path for the downloaded subscription content. func (s *Subscription) GetFilePath(configDir string) string { return filepath.Join(configDir, "subscriptions", s.Name+".yml") } // SubscriptionsData is the full persisted collection of subscriptions. type SubscriptionsData struct { Subscriptions map[string]*Subscription `yaml:"subscriptions"` } // NewSubscriptionsData returns an empty, ready-to-use collection. func NewSubscriptionsData() *SubscriptionsData { return &SubscriptionsData{Subscriptions: make(map[string]*Subscription)} } // GetActiveSubscriptions returns every subscription currently marked active, // sorted by name for deterministic ordering. Multiple subscriptions may be // active simultaneously — config apply merges all of them (see // internal/corecfg), prefixing each proxy's name with its source // subscription's name to avoid collisions. func (d *SubscriptionsData) GetActiveSubscriptions() []*Subscription { var actives []*Subscription for _, sub := range d.Subscriptions { if sub.Status == StatusActive { actives = append(actives, sub) } } sort.Slice(actives, func(i, j int) bool { return actives[i].Name < actives[j].Name }) return actives } // SetActive marks name as active, in addition to any other subscriptions // already active. Returns false if name does not exist. func (d *SubscriptionsData) SetActive(name string) bool { sub, ok := d.Subscriptions[name] if !ok { return false } sub.Status = StatusActive return true } // SetInactive marks name as inactive. Returns false if name does not exist. func (d *SubscriptionsData) SetInactive(name string) bool { sub, ok := d.Subscriptions[name] if !ok { return false } sub.Status = StatusInactive return true } // AddSubscription adds a new subscription, activating it if it's the first one. func (d *SubscriptionsData) AddSubscription(name, url string) *Subscription { sub := NewSubscription(name, url) if len(d.Subscriptions) == 0 { sub.Status = StatusActive } d.Subscriptions[name] = sub return sub } // RemoveSubscription deletes a subscription by name. Returns false if it // doesn't exist. func (d *SubscriptionsData) RemoveSubscription(name string) bool { if _, ok := d.Subscriptions[name]; !ok { return false } delete(d.Subscriptions, name) return true } // RenameSubscription renames oldName to newName. Returns false if oldName is // missing or newName is already taken. func (d *SubscriptionsData) RenameSubscription(oldName, newName string) bool { sub, ok := d.Subscriptions[oldName] if !ok { return false } if _, taken := d.Subscriptions[newName]; taken { return false } delete(d.Subscriptions, oldName) sub.Name = newName d.Subscriptions[newName] = sub return true } // SetSubscriptionURL updates the URL for an existing subscription. func (d *SubscriptionsData) SetSubscriptionURL(name, url string) bool { sub, ok := d.Subscriptions[name] if !ok { return false } sub.URL = url return true } // ValidationError is returned by Validate methods, mirroring pydantic's // ValueError-raising validators. type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { return fmt.Sprintf("%s: %s", e.Field, e.Message) }