feat: reorder subscriptions
This commit is contained in:
@ -11,9 +11,9 @@ scientific-surfing (`ssm`) CLI and their roles. The CLI is a single Go binary
|
|||||||
## Agent List
|
## Agent List
|
||||||
|
|
||||||
### 1. Subscription Manager (`internal/subscription`)
|
### 1. Subscription Manager (`internal/subscription`)
|
||||||
- **Purpose:** Handles all subscription-related operations, including adding, refreshing, deleting, renaming, and activating/deactivating subscriptions. Also parses `ss://`/`trojan://`/`vless://` links and base64-encoded link lists into Clash proxy YAML.
|
- **Purpose:** Handles all subscription-related operations, including adding, refreshing, deleting, renaming, reordering, and activating/deactivating subscriptions. Also parses `ss://`/`trojan://`/`vless://` links and base64-encoded link lists into Clash proxy YAML.
|
||||||
- **Key Methods:** `AddSubscription`, `RefreshSubscription`, `DeleteSubscription`, `RenameSubscription`, `SetSubscriptionURL`, `GetSubscriptionURL`, `ActivateSubscription`, `DeactivateSubscription`, `ListSubscriptions`, `ShowStorageInfo`
|
- **Key Methods:** `AddSubscription`, `RefreshSubscription`, `DeleteSubscription`, `RenameSubscription`, `SetSubscriptionURL`, `GetSubscriptionURL`, `ActivateSubscription`, `DeactivateSubscription`, `MoveSubscription`, `ListSubscriptions`, `ShowStorageInfo`
|
||||||
- **Notes:** Supports backup option on refresh. Activation is additive — multiple subscriptions can be active at once (`model.SubscriptionsData.SetActive` no longer deactivates others); `config apply` merges all of them.
|
- **Notes:** Supports backup option on refresh. Activation is additive — multiple subscriptions can be active at once (`model.SubscriptionsData.SetActive` no longer deactivates others); `config apply` merges all of them, in display order (`model.SubscriptionsData.OrderedNames`/`MoveSubscription`, persisted as `order:` in subscriptions.yaml) — so `subscription move` controls the order proxies/proxy-groups from different subscriptions appear in the generated config.
|
||||||
|
|
||||||
### 2. Storage Manager (`internal/storage`)
|
### 2. Storage Manager (`internal/storage`)
|
||||||
- **Purpose:** Manages persistent storage for configuration and subscription data as YAML files under the per-user config directory (`$SF_CONFIG_DIR` or `~/basicfiles/cli/ss`).
|
- **Purpose:** Manages persistent storage for configuration and subscription data as YAML files under the per-user config directory (`$SF_CONFIG_DIR` or `~/basicfiles/cli/ss`).
|
||||||
|
|||||||
@ -52,6 +52,11 @@ ssm subscription activate <name>
|
|||||||
# deactivate a subscription
|
# deactivate a subscription
|
||||||
ssm subscription deactivate <name>
|
ssm subscription deactivate <name>
|
||||||
|
|
||||||
|
# move a subscription before/after another (affects list display order and
|
||||||
|
# config apply's merge order — e.g. proxy/proxy-group ordering)
|
||||||
|
ssm subscription move <name> --before <target>
|
||||||
|
ssm subscription move <name> --after <target>
|
||||||
|
|
||||||
# list all subscriptions
|
# list all subscriptions
|
||||||
ssm subscription list
|
ssm subscription list
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import "github.com/spf13/cobra"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
func newSubscriptionCmd() *cobra.Command {
|
func newSubscriptionCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@ -17,6 +21,7 @@ func newSubscriptionCmd() *cobra.Command {
|
|||||||
newSubscriptionGetURLCmd(),
|
newSubscriptionGetURLCmd(),
|
||||||
newSubscriptionActivateCmd(),
|
newSubscriptionActivateCmd(),
|
||||||
newSubscriptionDeactivateCmd(),
|
newSubscriptionDeactivateCmd(),
|
||||||
|
newSubscriptionMoveCmd(),
|
||||||
newSubscriptionListCmd(),
|
newSubscriptionListCmd(),
|
||||||
newSubscriptionStorageCmd(),
|
newSubscriptionStorageCmd(),
|
||||||
)
|
)
|
||||||
@ -162,6 +167,37 @@ func newSubscriptionDeactivateCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newSubscriptionMoveCmd() *cobra.Command {
|
||||||
|
var before, after string
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: "move <name> --before|--after <target>",
|
||||||
|
Short: "Move a subscription before or after another in the list (affects display and config apply merge order)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if (before == "") == (after == "") {
|
||||||
|
return fmt.Errorf("specify exactly one of --before or --after")
|
||||||
|
}
|
||||||
|
|
||||||
|
target := before
|
||||||
|
isBefore := true
|
||||||
|
if after != "" {
|
||||||
|
target = after
|
||||||
|
isBefore = false
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := newManagers("", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Subscription.MoveSubscription(args[0], target, isBefore)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.Flags().StringVar(&before, "before", "", "Move the subscription immediately before this one")
|
||||||
|
c.Flags().StringVar(&after, "after", "", "Move the subscription immediately after this one")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
func newSubscriptionListCmd() *cobra.Command {
|
func newSubscriptionListCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
|
|||||||
@ -50,6 +50,13 @@ func (s *Subscription) GetFilePath(configDir string) string {
|
|||||||
// SubscriptionsData is the full persisted collection of subscriptions.
|
// SubscriptionsData is the full persisted collection of subscriptions.
|
||||||
type SubscriptionsData struct {
|
type SubscriptionsData struct {
|
||||||
Subscriptions map[string]*Subscription `yaml:"subscriptions"`
|
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.
|
// NewSubscriptionsData returns an empty, ready-to-use collection.
|
||||||
@ -57,22 +64,90 @@ func NewSubscriptionsData() *SubscriptionsData {
|
|||||||
return &SubscriptionsData{Subscriptions: make(map[string]*Subscription)}
|
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,
|
// GetActiveSubscriptions returns every subscription currently marked active,
|
||||||
// sorted by name for deterministic ordering. Multiple subscriptions may be
|
// in display/merge order (see OrderedNames). Multiple subscriptions may be
|
||||||
// active simultaneously — config apply merges all of them (see
|
// active simultaneously — config apply merges all of them (see
|
||||||
// internal/corecfg), prefixing each proxy's name with its source
|
// internal/corecfg), prefixing each proxy's name with its source
|
||||||
// subscription's name to avoid collisions.
|
// subscription's name to avoid collisions.
|
||||||
func (d *SubscriptionsData) GetActiveSubscriptions() []*Subscription {
|
func (d *SubscriptionsData) GetActiveSubscriptions() []*Subscription {
|
||||||
var actives []*Subscription
|
var actives []*Subscription
|
||||||
for _, sub := range d.Subscriptions {
|
for _, name := range d.OrderedNames() {
|
||||||
if sub.Status == StatusActive {
|
if sub := d.Subscriptions[name]; sub.Status == StatusActive {
|
||||||
actives = append(actives, sub)
|
actives = append(actives, sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(actives, func(i, j int) bool { return actives[i].Name < actives[j].Name })
|
|
||||||
return actives
|
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
|
// SetActive marks name as active, in addition to any other subscriptions
|
||||||
// already active. Returns false if name does not exist.
|
// already active. Returns false if name does not exist.
|
||||||
func (d *SubscriptionsData) SetActive(name string) bool {
|
func (d *SubscriptionsData) SetActive(name string) bool {
|
||||||
@ -94,13 +169,15 @@ func (d *SubscriptionsData) SetInactive(name string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSubscription adds a new subscription, activating it if it's the first one.
|
// 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 {
|
func (d *SubscriptionsData) AddSubscription(name, url string) *Subscription {
|
||||||
sub := NewSubscription(name, url)
|
sub := NewSubscription(name, url)
|
||||||
if len(d.Subscriptions) == 0 {
|
if len(d.Subscriptions) == 0 {
|
||||||
sub.Status = StatusActive
|
sub.Status = StatusActive
|
||||||
}
|
}
|
||||||
d.Subscriptions[name] = sub
|
d.Subscriptions[name] = sub
|
||||||
|
d.Order = append(d.Order, name)
|
||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,11 +188,13 @@ func (d *SubscriptionsData) RemoveSubscription(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
delete(d.Subscriptions, name)
|
delete(d.Subscriptions, name)
|
||||||
|
d.Order = removeString(d.Order, name)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenameSubscription renames oldName to newName. Returns false if oldName is
|
// RenameSubscription renames oldName to newName, preserving its position in
|
||||||
// missing or newName is already taken.
|
// the display/merge order. Returns false if oldName is missing or newName is
|
||||||
|
// already taken.
|
||||||
func (d *SubscriptionsData) RenameSubscription(oldName, newName string) bool {
|
func (d *SubscriptionsData) RenameSubscription(oldName, newName string) bool {
|
||||||
sub, ok := d.Subscriptions[oldName]
|
sub, ok := d.Subscriptions[oldName]
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -127,9 +206,24 @@ func (d *SubscriptionsData) RenameSubscription(oldName, newName string) bool {
|
|||||||
delete(d.Subscriptions, oldName)
|
delete(d.Subscriptions, oldName)
|
||||||
sub.Name = newName
|
sub.Name = newName
|
||||||
d.Subscriptions[newName] = sub
|
d.Subscriptions[newName] = sub
|
||||||
|
for i, n := range d.Order {
|
||||||
|
if n == oldName {
|
||||||
|
d.Order[i] = newName
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
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.
|
// SetSubscriptionURL updates the URL for an existing subscription.
|
||||||
func (d *SubscriptionsData) SetSubscriptionURL(name, url string) bool {
|
func (d *SubscriptionsData) SetSubscriptionURL(name, url string) bool {
|
||||||
sub, ok := d.Subscriptions[name]
|
sub, ok := d.Subscriptions[name]
|
||||||
|
|||||||
167
internal/model/subscription_test.go
Normal file
167
internal/model/subscription_test.go
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestData(names ...string) *SubscriptionsData {
|
||||||
|
d := NewSubscriptionsData()
|
||||||
|
for _, n := range names {
|
||||||
|
d.AddSubscription(n, "http://example.com/"+n)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderedNames_FollowsAdditionOrder(t *testing.T) {
|
||||||
|
d := newTestData("c", "a", "b")
|
||||||
|
want := []string{"c", "a", "b"}
|
||||||
|
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderedNames_MissingFromOrderAppendedAlphabetically(t *testing.T) {
|
||||||
|
d := newTestData("b", "a")
|
||||||
|
// Simulate a subscriptions.yaml predating the Order field, or a
|
||||||
|
// subscription added by some other path that didn't touch Order.
|
||||||
|
d.Order = nil
|
||||||
|
d.Subscriptions["z"] = NewSubscription("z", "http://example.com/z")
|
||||||
|
|
||||||
|
got := d.OrderedNames()
|
||||||
|
want := []string{"a", "b", "z"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderedNames_StaleOrderEntryIgnored(t *testing.T) {
|
||||||
|
d := newTestData("a", "b")
|
||||||
|
d.Order = []string{"a", "removed", "b"}
|
||||||
|
got := d.OrderedNames()
|
||||||
|
want := []string{"a", "b"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubscription_Before(t *testing.T) {
|
||||||
|
d := newTestData("a", "b", "c")
|
||||||
|
if !d.MoveSubscription("c", "a", true) {
|
||||||
|
t.Fatal("MoveSubscription() = false, want true")
|
||||||
|
}
|
||||||
|
want := []string{"c", "a", "b"}
|
||||||
|
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubscription_After(t *testing.T) {
|
||||||
|
d := newTestData("a", "b", "c")
|
||||||
|
if !d.MoveSubscription("a", "b", false) {
|
||||||
|
t.Fatal("MoveSubscription() = false, want true")
|
||||||
|
}
|
||||||
|
want := []string{"b", "a", "c"}
|
||||||
|
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubscription_AfterLast(t *testing.T) {
|
||||||
|
d := newTestData("a", "b", "c")
|
||||||
|
if !d.MoveSubscription("a", "c", false) {
|
||||||
|
t.Fatal("MoveSubscription() = false, want true")
|
||||||
|
}
|
||||||
|
want := []string{"b", "c", "a"}
|
||||||
|
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubscription_BeforeFirst(t *testing.T) {
|
||||||
|
d := newTestData("a", "b", "c")
|
||||||
|
if !d.MoveSubscription("c", "a", true) {
|
||||||
|
t.Fatal("MoveSubscription() = false, want true")
|
||||||
|
}
|
||||||
|
want := []string{"c", "a", "b"}
|
||||||
|
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubscription_NoOpWhenSameName(t *testing.T) {
|
||||||
|
d := newTestData("a", "b")
|
||||||
|
if d.MoveSubscription("a", "a", true) {
|
||||||
|
t.Fatal("MoveSubscription(a, a) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubscription_UnknownName(t *testing.T) {
|
||||||
|
d := newTestData("a", "b")
|
||||||
|
if d.MoveSubscription("nope", "a", true) {
|
||||||
|
t.Fatal("MoveSubscription() = true, want false for unknown name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubscription_UnknownTarget(t *testing.T) {
|
||||||
|
d := newTestData("a", "b")
|
||||||
|
if d.MoveSubscription("a", "nope", true) {
|
||||||
|
t.Fatal("MoveSubscription() = true, want false for unknown target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubscription_MaterializesFullOrderFromLegacyData(t *testing.T) {
|
||||||
|
// A subscriptions.yaml predating the Order field: Subscriptions
|
||||||
|
// populated directly, Order left nil, as if freshly loaded from an old
|
||||||
|
// file.
|
||||||
|
d := &SubscriptionsData{Subscriptions: map[string]*Subscription{
|
||||||
|
"a": NewSubscription("a", "http://example.com/a"),
|
||||||
|
"b": NewSubscription("b", "http://example.com/b"),
|
||||||
|
"c": NewSubscription("c", "http://example.com/c"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
if !d.MoveSubscription("c", "a", true) {
|
||||||
|
t.Fatal("MoveSubscription() = false, want true")
|
||||||
|
}
|
||||||
|
// Legacy fallback order is alphabetical (a, b, c); moving c before a
|
||||||
|
// should yield c, a, b.
|
||||||
|
want := []string{"c", "a", "b"}
|
||||||
|
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenameSubscription_PreservesPosition(t *testing.T) {
|
||||||
|
d := newTestData("a", "b", "c")
|
||||||
|
if !d.RenameSubscription("b", "bee") {
|
||||||
|
t.Fatal("RenameSubscription() = false")
|
||||||
|
}
|
||||||
|
want := []string{"a", "bee", "c"}
|
||||||
|
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveSubscription_RemovesFromOrder(t *testing.T) {
|
||||||
|
d := newTestData("a", "b", "c")
|
||||||
|
if !d.RemoveSubscription("b") {
|
||||||
|
t.Fatal("RemoveSubscription() = false")
|
||||||
|
}
|
||||||
|
want := []string{"a", "c"}
|
||||||
|
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetActiveSubscriptions_RespectsOrder(t *testing.T) {
|
||||||
|
d := newTestData("a", "b", "c")
|
||||||
|
d.SetInactive("a") // AddSubscription auto-activates the first one added
|
||||||
|
d.SetActive("b")
|
||||||
|
d.SetActive("c")
|
||||||
|
d.MoveSubscription("c", "a", true)
|
||||||
|
|
||||||
|
actives := d.GetActiveSubscriptions()
|
||||||
|
if len(actives) != 2 || actives[0].Name != "c" || actives[1].Name != "b" {
|
||||||
|
t.Fatalf("unexpected order: %#v", actives)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -206,7 +206,8 @@ func (m *Manager) DeactivateSubscription(name string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSubscriptions prints all subscriptions.
|
// ListSubscriptions prints all subscriptions in display order (see
|
||||||
|
// model.SubscriptionsData.OrderedNames / `subscription move`).
|
||||||
func (m *Manager) ListSubscriptions() {
|
func (m *Manager) ListSubscriptions() {
|
||||||
if len(m.Data.Subscriptions) == 0 {
|
if len(m.Data.Subscriptions) == 0 {
|
||||||
fmt.Println("No subscriptions found")
|
fmt.Println("No subscriptions found")
|
||||||
@ -214,7 +215,8 @@ func (m *Manager) ListSubscriptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("📋 Subscriptions:")
|
fmt.Println("📋 Subscriptions:")
|
||||||
for name, sub := range m.Data.Subscriptions {
|
for _, name := range m.Data.OrderedNames() {
|
||||||
|
sub := m.Data.Subscriptions[name]
|
||||||
activeMarker := "⚪"
|
activeMarker := "⚪"
|
||||||
if sub.Status == model.StatusActive {
|
if sub.Status == model.StatusActive {
|
||||||
activeMarker = "✅"
|
activeMarker = "✅"
|
||||||
@ -229,6 +231,28 @@ func (m *Manager) ListSubscriptions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveSubscription repositions name immediately before or after target in
|
||||||
|
// the display/merge order.
|
||||||
|
func (m *Manager) MoveSubscription(name, target string, before bool) {
|
||||||
|
if m.Data.MoveSubscription(name, target, before) {
|
||||||
|
if m.Storage.SaveSubscriptions(m.Data) {
|
||||||
|
position := "after"
|
||||||
|
if before {
|
||||||
|
position = "before"
|
||||||
|
}
|
||||||
|
fmt.Printf("✅ Moved subscription '%s' %s '%s'\n", name, position, target)
|
||||||
|
} else {
|
||||||
|
fmt.Println("❌ Failed to move subscription")
|
||||||
|
}
|
||||||
|
} else if name == target {
|
||||||
|
fmt.Println("❌ Cannot move a subscription relative to itself")
|
||||||
|
} else if _, ok := m.Data.Subscriptions[name]; !ok {
|
||||||
|
fmt.Printf("❌ Subscription '%s' not found\n", name)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("❌ Subscription '%s' not found\n", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubscriptionURL prints the URL for a subscription.
|
// GetSubscriptionURL prints the URL for a subscription.
|
||||||
func (m *Manager) GetSubscriptionURL(name string) {
|
func (m *Manager) GetSubscriptionURL(name string) {
|
||||||
sub, ok := m.Data.Subscriptions[name]
|
sub, ok := m.Data.Subscriptions[name]
|
||||||
|
|||||||
Reference in New Issue
Block a user