Files

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
}