Files
new-api/model/channel_priority.go
CaIon 5e9595190e feat(channels): add drag mode for channel priority ordering
Support priority ordering in table and card views with automatic priority calculation, same-tier and between-tier drop targets, live previews, conflict detection, and undo.

Keep drag mode off by default and place its toggle before Batch Operations. Consolidate sorting controls and preserve the shared visibility and view controls.
2026-10-17 20:19:41 +08:00

245 lines
7.3 KiB
Go

package model
import (
"crypto/sha256"
"errors"
"fmt"
"slices"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// Keep priorities lossless in the web console's JSON number representation.
const maxSortablePriority int64 = 1<<53 - 1
var ErrChannelPriorityConflict = errors.New("Channel priorities changed. Refresh and try again.")
var ErrChannelPriorityInvalid = errors.New("Invalid channel priority move")
var ErrChannelPriorityRange = errors.New("Channel priority is outside the sortable range")
type ChannelPriority struct {
ID int `json:"id"`
Priority *int64 `json:"priority"`
}
func (item ChannelPriority) value() int64 {
if item.Priority == nil {
return 0
}
return *item.Priority
}
type ChannelPriorityChange struct {
ID int `json:"id"`
Previous *int64 `json:"previous"`
Priority *int64 `json:"priority"`
}
type ChannelPriorityOrder struct {
Revision string `json:"revision"`
Items []ChannelPriority `json:"items"`
Changes []ChannelPriorityChange `json:"changes"`
}
type ChannelPriorityMove struct {
Revision string `json:"revision"`
ID int `json:"id"`
TargetID int `json:"target_id"`
Position string `json:"position"` // before / after use descending priority order
Preview bool `json:"preview"`
Restore []ChannelPriorityChange `json:"restore,omitempty"`
}
func channelPriorityRevision(items []ChannelPriority) string {
data, _ := common.Marshal(items)
return fmt.Sprintf("%x", sha256.Sum256(data))
}
func GetChannelPriorityOrder() (ChannelPriorityOrder, error) {
result := ChannelPriorityOrder{Items: []ChannelPriority{}, Changes: []ChannelPriorityChange{}}
err := DB.Model(&Channel{}).Select("id", "priority").Order("id").Find(&result.Items).Error
result.Revision = channelPriorityRevision(result.Items)
return result, err
}
// ReorderChannelPriority updates only priorities, including the routing copy in
// abilities. A snapshot check also protects preview/commit and undo from stale
// clients; row locks serialize concurrent reorders on MySQL and PostgreSQL.
func ReorderChannelPriority(move ChannelPriorityMove) (ChannelPriorityOrder, error) {
result := ChannelPriorityOrder{Items: []ChannelPriority{}, Changes: []ChannelPriorityChange{}}
err := DB.Transaction(func(tx *gorm.DB) error {
query := tx.Model(&Channel{}).Select("id", "priority").Order("id")
if !move.Preview {
query = lockForUpdate(query)
}
if err := query.Find(&result.Items).Error; err != nil {
return err
}
if move.Revision == "" || move.Revision != channelPriorityRevision(result.Items) {
return ErrChannelPriorityConflict
}
changes, err := planChannelPriorityMove(result.Items, move)
if err != nil {
return err
}
result.Changes = changes
byID := make(map[int]*ChannelPriority, len(result.Items))
for i := range result.Items {
byID[result.Items[i].ID] = &result.Items[i]
}
for _, change := range changes {
byID[change.ID].Priority = change.Priority
if move.Preview {
continue
}
if err := tx.Model(&Channel{}).Where("id = ?", change.ID).Update("priority", change.Priority).Error; err != nil {
return err
}
if err := tx.Model(&Ability{}).Where("channel_id = ?", change.ID).Update("priority", change.Priority).Error; err != nil {
return err
}
}
result.Revision = channelPriorityRevision(result.Items)
return nil
})
return result, err
}
// Equal-priority channels remain together. Opening a slot shifts the shorter
// contiguous run of occupied integer priorities, measured by affected channels.
func planChannelPriorityMove(items []ChannelPriority, move ChannelPriorityMove) ([]ChannelPriorityChange, error) {
changes := []ChannelPriorityChange{}
byID := make(map[int]ChannelPriority, len(items))
for _, item := range items {
if item.value() < -maxSortablePriority || item.value() > maxSortablePriority {
return nil, ErrChannelPriorityRange
}
byID[item.ID] = item
}
if len(move.Restore) > 0 {
if move.ID != 0 || move.TargetID != 0 || move.Position != "" {
return nil, ErrChannelPriorityInvalid
}
seen := make(map[int]bool, len(move.Restore))
for _, change := range move.Restore {
item, ok := byID[change.ID]
if !ok || seen[change.ID] || (change.Priority != nil && (*change.Priority < -maxSortablePriority || *change.Priority > maxSortablePriority)) {
return nil, ErrChannelPriorityInvalid
}
if !equalPriority(item.Priority, change.Previous) {
return nil, ErrChannelPriorityConflict
}
seen[change.ID] = true
if !equalPriority(item.Priority, change.Priority) {
changes = append(changes, change)
}
}
return changes, nil
}
item, ok := byID[move.ID]
target, targetOK := byID[move.TargetID]
if !ok || !targetOK || move.ID == move.TargetID || !slices.Contains([]string{"before", "after", "same"}, move.Position) {
return nil, ErrChannelPriorityInvalid
}
values := make(map[int]int64, len(items))
for _, entry := range items {
values[entry.ID] = entry.value()
}
if move.Position == "same" {
values[item.ID] = target.value()
} else {
counts := make(map[int64]int)
for _, entry := range items {
if entry.ID != item.ID {
counts[entry.value()]++
}
}
levels := make([]int64, 0, len(counts))
for level := range counts {
levels = append(levels, level)
}
slices.SortFunc(levels, func(a, b int64) int {
if a > b {
return -1
}
if a < b {
return 1
}
return 0
})
index := slices.Index(levels, target.value())
if move.Position == "after" {
index++
}
hi, lo := maxSortablePriority+1, -maxSortablePriority-1
if index > 0 {
hi = levels[index-1]
}
if index < len(levels) {
lo = levels[index]
}
// Dropping into the channel's existing slot must not renumber it.
if item.value() < hi && item.value() > lo {
return changes, nil
}
if hi-lo < 2 {
up, down := index-1, index
upCost, downCost := 0, 0
for up >= 0 {
upCost += counts[levels[up]]
if (up == 0 && levels[up] < maxSortablePriority) || (up > 0 && levels[up-1]-levels[up] > 1) {
break
}
up--
}
for down < len(levels) {
downCost += counts[levels[down]]
if (down == len(levels)-1 && levels[down] > -maxSortablePriority) || (down+1 < len(levels) && levels[down]-levels[down+1] > 1) {
break
}
down++
}
canUp, canDown := up >= 0 && index > 0, down < len(levels) && index < len(levels)
if !canUp && !canDown {
return nil, ErrChannelPriorityRange
}
if canUp && (!canDown || upCost < downCost) {
for id, value := range values {
if id != item.ID && value >= levels[index-1] && value <= levels[up] {
values[id]++
}
}
hi++
} else {
for id, value := range values {
if id != item.ID && value <= levels[index] && value >= levels[down] {
values[id]--
}
}
lo--
}
}
value := lo + (hi-lo)/2
if index == 0 {
value = min(maxSortablePriority, lo+10)
} else if index == len(levels) {
value = max(-maxSortablePriority, hi-10)
}
values[item.ID] = value
}
for _, entry := range items {
value := values[entry.ID]
if value != entry.value() {
changes = append(changes, ChannelPriorityChange{ID: entry.ID, Previous: entry.Priority, Priority: &value})
}
}
return changes, nil
}
func equalPriority(a, b *int64) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}