Files
canon/internal/store/places.go
T
cdnutterandClaude Opus 5.5 1f3fa4fb2b Pace IGDB requests; rewrite the new help text
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>
2026-10-08 14:51:09 -07:00

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
}