IGDB allows 4 requests a second, and an import searching three entries at once (plus detail lookups) went over it. Every IGDB request now waits at least 300 ms after the last one, and a 429 is retried after 1, 2 and 4 seconds before giving up. The import dialog, ranking editor and their error messages are reworded in plainer language, and talk about "spots" instead of "places". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
8.1 KiB
Go
339 lines
8.1 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"sort"
|
|
)
|
|
|
|
// A list's entries sit at places 1…max_items, and places can be empty: an
|
|
// entry can be #28 with nothing at #29 and the next at #30. The functions
|
|
// here decide where entries go; every change is written in one transaction.
|
|
//
|
|
// The rules, so that lists without gaps behave like an ordinary ranking:
|
|
// - Moving onto an empty place just puts the entry there. Its old place
|
|
// becomes empty.
|
|
// - Moving onto a taken place shifts the entries in between one step
|
|
// toward where it came from, stopping at the first empty place.
|
|
// - Inserting (a new entry, or an empty place) pushes entries down only as
|
|
// far as the next empty place, which absorbs the shift. Entries placed
|
|
// further down stay put.
|
|
|
|
// places maps place → entry id.
|
|
type places map[int]int64
|
|
|
|
func (p places) last() int {
|
|
n := 0
|
|
for r := range p {
|
|
n = max(n, r)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// shift moves the entries at places [from, to] by d (±1), walking in the
|
|
// direction that never overwrites an entry before it's moved.
|
|
func (p places) shift(from, to, d int) {
|
|
if from > to {
|
|
return
|
|
}
|
|
if d > 0 {
|
|
for r := to; r >= from; r-- {
|
|
if id, ok := p[r]; ok {
|
|
delete(p, r)
|
|
p[r+d] = id
|
|
}
|
|
}
|
|
return
|
|
}
|
|
for r := from; r <= to; r++ {
|
|
if id, ok := p[r]; ok {
|
|
delete(p, r)
|
|
p[r+d] = id
|
|
}
|
|
}
|
|
}
|
|
|
|
// makeRoom frees place at by pushing entries down to the next empty place.
|
|
func (p places) makeRoom(at, limit int) error {
|
|
if _, taken := p[at]; !taken {
|
|
return nil
|
|
}
|
|
gap := at
|
|
for {
|
|
if _, taken := p[gap]; !taken {
|
|
break
|
|
}
|
|
gap++
|
|
}
|
|
if gap > limit {
|
|
return invalid("There's no room left. This list stops at #%d; make it bigger in List settings or close up an open spot.", limit)
|
|
}
|
|
p.shift(at, gap-1, +1)
|
|
return nil
|
|
}
|
|
|
|
// move takes the entry at from to place to.
|
|
func (p places) move(from, to int) {
|
|
id := p[from]
|
|
delete(p, from)
|
|
if _, taken := p[to]; taken {
|
|
if to < from {
|
|
gap := to + 1 // the nearest empty place below to; from is one
|
|
for {
|
|
if _, t := p[gap]; !t {
|
|
break
|
|
}
|
|
gap++
|
|
}
|
|
p.shift(to, gap-1, +1)
|
|
} else {
|
|
gap := to - 1 // the nearest empty place above to
|
|
for {
|
|
if _, t := p[gap]; !t {
|
|
break
|
|
}
|
|
gap--
|
|
}
|
|
p.shift(gap+1, to, -1)
|
|
}
|
|
}
|
|
p[to] = id
|
|
}
|
|
|
|
// closeUp pulls the entries right below an emptied place up by one, as far
|
|
// as the next empty place.
|
|
func (p places) closeUp(at int) {
|
|
end := at + 1
|
|
for {
|
|
if _, t := p[end]; !t {
|
|
break
|
|
}
|
|
end++
|
|
}
|
|
p.shift(at+1, end-1, -1)
|
|
}
|
|
|
|
// compact numbers the entries 1, 2, 3… in order, removing every gap.
|
|
func (p places) compact() {
|
|
ranks := make([]int, 0, len(p))
|
|
for r := range p {
|
|
ranks = append(ranks, r)
|
|
}
|
|
sort.Ints(ranks)
|
|
ids := make([]int64, len(ranks))
|
|
for i, r := range ranks {
|
|
ids[i] = p[r]
|
|
delete(p, r)
|
|
}
|
|
for i, id := range ids {
|
|
p[i+1] = id
|
|
}
|
|
}
|
|
|
|
// Place is an entry's id and place, as returned after any change.
|
|
type Place struct {
|
|
ID int64 `json:"id"`
|
|
Rank int `json:"rank"`
|
|
}
|
|
|
|
// placeTx loads a list's places inside a transaction.
|
|
type placeTx struct {
|
|
tx *sql.Tx
|
|
listID int64
|
|
limit int // max_items
|
|
before map[int64]int
|
|
p places
|
|
}
|
|
|
|
func (s *Store) beginPlaces(ctx context.Context, listID int64) (*placeTx, error) {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pt := &placeTx{tx: tx, listID: listID, before: map[int64]int{}, p: places{}}
|
|
err = tx.QueryRowContext(ctx, `SELECT max_items FROM lists WHERE id = ?`, listID).Scan(&pt.limit)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
tx.Rollback()
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
rows, err := tx.QueryContext(ctx, `SELECT id, rank FROM items WHERE list_id = ?`, listID)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id int64
|
|
var r int
|
|
if err := rows.Scan(&id, &r); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
pt.before[id] = r
|
|
pt.p[r] = id
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
return pt, nil
|
|
}
|
|
|
|
// write saves the places that changed, without committing.
|
|
func (pt *placeTx) write(ctx context.Context) error {
|
|
if last := pt.p.last(); last > pt.limit {
|
|
return invalid("There's no room left. This list stops at #%d; make it bigger in List settings.", pt.limit)
|
|
}
|
|
stmt, err := pt.tx.PrepareContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
for r, id := range pt.p {
|
|
if old, ok := pt.before[id]; ok && old != r {
|
|
if _, err := stmt.ExecContext(ctx, r, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return touchList(ctx, pt.tx, pt.listID)
|
|
}
|
|
|
|
// commit writes the places that changed and returns them all.
|
|
func (pt *placeTx) commit(ctx context.Context) ([]Place, error) {
|
|
defer pt.tx.Rollback()
|
|
if err := pt.write(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pt.tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return pt.list(), nil
|
|
}
|
|
|
|
// appendPlace is where a new entry goes when no place is asked for: after
|
|
// the last one, or the first empty place if the list ends there.
|
|
func (p places) appendPlace(limit int) int {
|
|
if last := p.last(); last < limit {
|
|
return last + 1
|
|
}
|
|
for r := 1; r <= limit; r++ {
|
|
if _, t := p[r]; !t {
|
|
return r
|
|
}
|
|
}
|
|
return limit + 1
|
|
}
|
|
|
|
func (pt *placeTx) list() []Place {
|
|
out := make([]Place, 0, len(pt.p))
|
|
for r, id := range pt.p {
|
|
out = append(out, Place{ID: id, Rank: r})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Rank < out[j].Rank })
|
|
return out
|
|
}
|
|
|
|
func (pt *placeTx) checkPlace(at int) error {
|
|
if at < 1 || at > pt.limit {
|
|
return invalid("Pick a number from 1 to %d.", pt.limit)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Places returns where each of a list's entries sits.
|
|
func (s *Store) Places(ctx context.Context, listID int64) ([]Place, error) {
|
|
pt, err := s.beginPlaces(ctx, listID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer pt.tx.Rollback()
|
|
return pt.list(), nil
|
|
}
|
|
|
|
// MoveItem puts an entry at place to (see the rules above) and returns the
|
|
// list's places.
|
|
func (s *Store) MoveItem(ctx context.Context, id int64, to int) ([]Place, error) {
|
|
var listID int64
|
|
if err := s.db.QueryRowContext(ctx, `SELECT list_id FROM items WHERE id = ?`, id).Scan(&listID); errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
pt, err := s.beginPlaces(ctx, listID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer pt.tx.Rollback()
|
|
if err := pt.checkPlace(to); err != nil {
|
|
return nil, err
|
|
}
|
|
if from := pt.before[id]; from != to {
|
|
pt.p.move(from, to)
|
|
}
|
|
return pt.commit(ctx)
|
|
}
|
|
|
|
// InsertPlace opens an empty place at at, pushing entries down to the next
|
|
// empty place.
|
|
func (s *Store) InsertPlace(ctx context.Context, listID int64, at int) ([]Place, error) {
|
|
pt, err := s.beginPlaces(ctx, listID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer pt.tx.Rollback()
|
|
if err := pt.checkPlace(at); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pt.p.makeRoom(at, pt.limit); err != nil {
|
|
return nil, err
|
|
}
|
|
return pt.commit(ctx)
|
|
}
|
|
|
|
// RemovePlace deletes count empty places starting at at: everything below
|
|
// moves up by count.
|
|
func (s *Store) RemovePlace(ctx context.Context, listID int64, at, count int) ([]Place, error) {
|
|
pt, err := s.beginPlaces(ctx, listID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer pt.tx.Rollback()
|
|
if err := pt.checkPlace(at); err != nil {
|
|
return nil, err
|
|
}
|
|
count = max(count, 1)
|
|
for r := at; r < at+count; r++ {
|
|
if _, taken := pt.p[r]; taken {
|
|
return nil, invalid("#%d isn't open.", r)
|
|
}
|
|
}
|
|
for range count {
|
|
pt.p.shift(at+1, pt.p.last(), -1)
|
|
}
|
|
return pt.commit(ctx)
|
|
}
|
|
|
|
// CloseGaps renumbers a list's entries 1, 2, 3… keeping their order.
|
|
func (s *Store) CloseGaps(ctx context.Context, listID int64) ([]Place, error) {
|
|
pt, err := s.beginPlaces(ctx, listID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer pt.tx.Rollback()
|
|
pt.p.compact()
|
|
return pt.commit(ctx)
|
|
}
|
|
|
|
// MaxRank is the highest place used in a list, 0 when it's empty.
|
|
func (s *Store) MaxRank(ctx context.Context, listID int64) (int, error) {
|
|
var n int
|
|
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(rank), 0) FROM items WHERE list_id = ?`, listID).Scan(&n)
|
|
return n, err
|
|
}
|