feat: migrate to golang
This commit is contained in:
31
internal/model/config.go
Normal file
31
internal/model/config.go
Normal file
@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
// Config is the user-editable application configuration
|
||||
// (ports to Python's Config(BaseModel) in models.py).
|
||||
type Config struct {
|
||||
AutoRefresh bool `yaml:"auto_refresh"`
|
||||
RefreshIntervalHours int `yaml:"refresh_interval_hours"`
|
||||
DefaultUserAgent string `yaml:"default_user_agent"`
|
||||
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||
}
|
||||
|
||||
// DefaultConfig mirrors the Pydantic field defaults.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
AutoRefresh: false,
|
||||
RefreshIntervalHours: 24,
|
||||
DefaultUserAgent: "scientific-surfing/0.1.0",
|
||||
TimeoutSeconds: 30,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate mirrors the two @validator methods on Config in models.py.
|
||||
func (c Config) Validate() error {
|
||||
if c.RefreshIntervalHours < 1 {
|
||||
return &ValidationError{Field: "refresh_interval_hours", Message: "must be at least 1 hour"}
|
||||
}
|
||||
if c.TimeoutSeconds < 1 {
|
||||
return &ValidationError{Field: "timeout_seconds", Message: "must be at least 1 second"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
140
internal/model/subscription.go
Normal file
140
internal/model/subscription.go
Normal file
@ -0,0 +1,140 @@
|
||||
// Package model defines the persisted data structures for scientific-surfing.
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 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)}
|
||||
}
|
||||
|
||||
// GetActiveSubscription returns the currently active subscription, if any.
|
||||
func (d *SubscriptionsData) GetActiveSubscription() *Subscription {
|
||||
for _, sub := range d.Subscriptions {
|
||||
if sub.Status == StatusActive {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetActive marks name as active and deactivates all others. Returns false if
|
||||
// name does not exist.
|
||||
func (d *SubscriptionsData) SetActive(name string) bool {
|
||||
if _, ok := d.Subscriptions[name]; !ok {
|
||||
return false
|
||||
}
|
||||
for subName, sub := range d.Subscriptions {
|
||||
if subName == name {
|
||||
sub.Status = StatusActive
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
62
internal/model/time.go
Normal file
62
internal/model/time.go
Normal file
@ -0,0 +1,62 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// timeLayouts are tried in order when parsing a persisted timestamp. The
|
||||
// first two cover what this Go implementation itself writes (time zone
|
||||
// aware); the rest cover what the original Python implementation wrote via
|
||||
// datetime.now().isoformat() — no time zone, and microseconds included only
|
||||
// when non-zero (Go's parser accepts an optional fractional-seconds suffix
|
||||
// even when the layout doesn't specify one, so a single no-fraction layout
|
||||
// handles both cases).
|
||||
var timeLayouts = []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
}
|
||||
|
||||
// Time wraps time.Time to support reading timestamps in both this Go
|
||||
// implementation's own format and the original Python implementation's
|
||||
// timezone-naive datetime.isoformat() format (e.g.
|
||||
// "2026-03-19T18:21:47.740090"), which Go's default time.Time YAML/text
|
||||
// decoding rejects outright since it strictly requires RFC3339 (with a zone
|
||||
// offset).
|
||||
type Time struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// UnmarshalYAML tries each supported layout in turn.
|
||||
func (t *Time) UnmarshalYAML(value *yaml.Node) error {
|
||||
var s string
|
||||
if err := value.Decode(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" {
|
||||
t.Time = time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, layout := range timeLayouts {
|
||||
parsed, err := time.Parse(layout, s)
|
||||
if err == nil {
|
||||
t.Time = parsed
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return fmt.Errorf("cannot parse time %q: %w", s, lastErr)
|
||||
}
|
||||
|
||||
// MarshalYAML writes the timestamp in RFC3339Nano (timezone-aware) format.
|
||||
func (t Time) MarshalYAML() (interface{}, error) {
|
||||
if t.Time.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return t.Time.Format(time.RFC3339Nano), nil
|
||||
}
|
||||
91
internal/model/time_test.go
Normal file
91
internal/model/time_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestTime_UnmarshalPythonISOFormat(t *testing.T) {
|
||||
// Exact reproduction of the reported bug: a subscriptions.yaml written
|
||||
// by the original Python implementation (datetime.now().isoformat(),
|
||||
// no timezone) failed to parse under Go's strict RFC3339 decoding.
|
||||
data := []byte(`last_refresh: 2026-03-19T18:21:47.740090
|
||||
`)
|
||||
var out struct {
|
||||
LastRefresh *Time `yaml:"last_refresh"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if out.LastRefresh == nil {
|
||||
t.Fatal("expected non-nil LastRefresh")
|
||||
}
|
||||
want := time.Date(2026, 3, 19, 18, 21, 47, 740090000, time.UTC)
|
||||
if !out.LastRefresh.Time.Equal(want) {
|
||||
t.Fatalf("got %v, want %v", out.LastRefresh.Time, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTime_UnmarshalPythonISOFormatNoMicroseconds(t *testing.T) {
|
||||
data := []byte(`last_refresh: 2026-03-19T18:21:47
|
||||
`)
|
||||
var out struct {
|
||||
LastRefresh *Time `yaml:"last_refresh"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
want := time.Date(2026, 3, 19, 18, 21, 47, 0, time.UTC)
|
||||
if !out.LastRefresh.Time.Equal(want) {
|
||||
t.Fatalf("got %v, want %v", out.LastRefresh.Time, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTime_UnmarshalRFC3339(t *testing.T) {
|
||||
// What this Go implementation itself writes.
|
||||
data := []byte(`last_refresh: 2026-03-19T18:21:47.74009+08:00
|
||||
`)
|
||||
var out struct {
|
||||
LastRefresh *Time `yaml:"last_refresh"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if out.LastRefresh.Time.IsZero() {
|
||||
t.Fatal("expected non-zero time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTime_RoundTrip(t *testing.T) {
|
||||
original := Time{Time: time.Date(2026, 3, 19, 18, 21, 47, 740090000, time.UTC)}
|
||||
out, err := yaml.Marshal(map[string]interface{}{"last_refresh": original})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var roundTripped struct {
|
||||
LastRefresh Time `yaml:"last_refresh"`
|
||||
}
|
||||
if err := yaml.Unmarshal(out, &roundTripped); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if !roundTripped.LastRefresh.Time.Equal(original.Time) {
|
||||
t.Fatalf("round trip mismatch: got %v, want %v", roundTripped.LastRefresh.Time, original.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTime_UnmarshalEmpty(t *testing.T) {
|
||||
data := []byte(`last_refresh: ""
|
||||
`)
|
||||
var out struct {
|
||||
LastRefresh Time `yaml:"last_refresh"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if !out.LastRefresh.Time.IsZero() {
|
||||
t.Fatalf("expected zero time, got %v", out.LastRefresh.Time)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user