// 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"` // Order records the user-controlled display/merge order of // subscription names (see `subscription move`). Subscriptions absent // from Order (new ones, or a subscriptions.yaml predating this field) // are appended alphabetically after it by OrderedNames — Order need // not be exhaustive or kept in sync by every mutation, it's reconciled // against Subscriptions on read. Order []string `yaml:"order,omitempty"` } // NewSubscriptionsData returns an empty, ready-to-use collection. func NewSubscriptionsData() *SubscriptionsData { return &SubscriptionsData{Subscriptions: make(map[string]*Subscription)} } // OrderedNames returns every subscription name in display/merge order: // Order's entries first (skipping any that no longer exist), then any // subscription missing from Order (new, or loaded from a file predating // this field), sorted alphabetically. func (d *SubscriptionsData) OrderedNames() []string { seen := make(map[string]bool, len(d.Subscriptions)) result := make([]string, 0, len(d.Subscriptions)) for _, name := range d.Order { if _, ok := d.Subscriptions[name]; ok && !seen[name] { result = append(result, name) seen[name] = true } } var missing []string for name := range d.Subscriptions { if !seen[name] { missing = append(missing, name) } } sort.Strings(missing) return append(result, missing...) } // GetActiveSubscriptions returns every subscription currently marked active, // in display/merge order (see OrderedNames). 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 _, name := range d.OrderedNames() { if sub := d.Subscriptions[name]; sub.Status == StatusActive { actives = append(actives, sub) } } return actives } // MoveSubscription repositions name immediately before or after target in // the display/merge order. Returns false if name or target don't exist, or // name == target. func (d *SubscriptionsData) MoveSubscription(name, target string, before bool) bool { if name == target { return false } if _, ok := d.Subscriptions[name]; !ok { return false } if _, ok := d.Subscriptions[target]; !ok { return false } current := d.OrderedNames() filtered := make([]string, 0, len(current)) for _, n := range current { if n != name { filtered = append(filtered, n) } } targetIdx := -1 for i, n := range filtered { if n == target { targetIdx = i break } } insertAt := targetIdx if !before { insertAt++ } result := make([]string, 0, len(filtered)+1) result = append(result, filtered[:insertAt]...) result = append(result, name) result = append(result, filtered[insertAt:]...) d.Order = result return true } // 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, and appends it to the end of the display/merge order. func (d *SubscriptionsData) AddSubscription(name, url string) *Subscription { sub := NewSubscription(name, url) if len(d.Subscriptions) == 0 { sub.Status = StatusActive } d.Subscriptions[name] = sub d.Order = append(d.Order, name) 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) d.Order = removeString(d.Order, name) return true } // RenameSubscription renames oldName to newName, preserving its position in // the display/merge order. 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 for i, n := range d.Order { if n == oldName { d.Order[i] = newName } } return true } func removeString(list []string, s string) []string { out := list[:0] for _, v := range list { if v != s { out = append(out, v) } } return out } // 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) }