247 lines
7.0 KiB
Go
247 lines
7.0 KiB
Go
// 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)
|
|
}
|