diff --git a/ircbot/commands_impl.go b/ircbot/commands_impl.go index 3818cc52..3899b700 100644 --- a/ircbot/commands_impl.go +++ b/ircbot/commands_impl.go @@ -66,7 +66,7 @@ func NowPlaying(e Event) error { func LastPlayed(e Event) error { const op errors.Op = "irc/LastPlayed" - songs, err := e.Storage.Song(e.Ctx).LastPlayed(0, 5) + songs, err := e.Storage.Song(e.Ctx).LastPlayed(radio.LPKeyLast, 5) if err != nil { return errors.E(op, err) } @@ -230,7 +230,23 @@ func FaveTrack(e Event) error { // count the amount of `last`'s used to determine how far back we should go index := strings.Count(e.Arguments["relative"], "last") - 1 - songs, err := ss.LastPlayed(int64(index), 1) + + key := radio.LPKeyLast + if index > 0 { + // if our index is higher than 0 we need to lookup the key for that + prev, _, err := ss.LastPlayedPagination(radio.LPKeyLast, 1, 50) + if err != nil { + return errors.E(op, err) + } + // make sure our index exists in the list + if index >= len(prev) { + return errors.E(op, "index too far") + } + + key = prev[index] + } + + songs, err := ss.LastPlayed(key, 1) if err != nil { return errors.E(op, err) } diff --git a/mocks/radio.gen.go b/mocks/radio.gen.go index 82c5d4ab..46883165 100644 --- a/mocks/radio.gen.go +++ b/mocks/radio.gen.go @@ -3079,12 +3079,15 @@ var _ radio.SongStorage = &SongStorageMock{} // FromMetadataFunc: func(metadata string) (*radio.Song, error) { // panic("mock out the FromMetadata method") // }, -// LastPlayedFunc: func(offset int64, amount int64) ([]radio.Song, error) { +// LastPlayedFunc: func(key radio.LastPlayedKey, amountPerPage int) ([]radio.Song, error) { // panic("mock out the LastPlayed method") // }, // LastPlayedCountFunc: func() (int64, error) { // panic("mock out the LastPlayedCount method") // }, +// LastPlayedPaginationFunc: func(key radio.LastPlayedKey, amountPerPage int, pageCount int) ([]radio.LastPlayedKey, []radio.LastPlayedKey, error) { +// panic("mock out the LastPlayedPagination method") +// }, // PlayedCountFunc: func(song radio.Song) (int64, error) { // panic("mock out the PlayedCount method") // }, @@ -3132,11 +3135,14 @@ type SongStorageMock struct { FromMetadataFunc func(metadata string) (*radio.Song, error) // LastPlayedFunc mocks the LastPlayed method. - LastPlayedFunc func(offset int64, amount int64) ([]radio.Song, error) + LastPlayedFunc func(key radio.LastPlayedKey, amountPerPage int) ([]radio.Song, error) // LastPlayedCountFunc mocks the LastPlayedCount method. LastPlayedCountFunc func() (int64, error) + // LastPlayedPaginationFunc mocks the LastPlayedPagination method. + LastPlayedPaginationFunc func(key radio.LastPlayedKey, amountPerPage int, pageCount int) ([]radio.LastPlayedKey, []radio.LastPlayedKey, error) + // PlayedCountFunc mocks the PlayedCount method. PlayedCountFunc func(song radio.Song) (int64, error) @@ -3208,14 +3214,23 @@ type SongStorageMock struct { } // LastPlayed holds details about calls to the LastPlayed method. LastPlayed []struct { - // Offset is the offset argument value. - Offset int64 - // Amount is the amount argument value. - Amount int64 + // Key is the key argument value. + Key radio.LastPlayedKey + // AmountPerPage is the amountPerPage argument value. + AmountPerPage int } // LastPlayedCount holds details about calls to the LastPlayedCount method. LastPlayedCount []struct { } + // LastPlayedPagination holds details about calls to the LastPlayedPagination method. + LastPlayedPagination []struct { + // Key is the key argument value. + Key radio.LastPlayedKey + // AmountPerPage is the amountPerPage argument value. + AmountPerPage int + // PageCount is the pageCount argument value. + PageCount int + } // PlayedCount holds details about calls to the PlayedCount method. PlayedCount []struct { // Song is the song argument value. @@ -3243,21 +3258,22 @@ type SongStorageMock struct { Duration time.Duration } } - lockAddFavorite sync.RWMutex - lockAddPlay sync.RWMutex - lockCreate sync.RWMutex - lockFavoriteCount sync.RWMutex - lockFavorites sync.RWMutex - lockFavoritesOf sync.RWMutex - lockFavoritesOfDatabase sync.RWMutex - lockFromHash sync.RWMutex - lockFromMetadata sync.RWMutex - lockLastPlayed sync.RWMutex - lockLastPlayedCount sync.RWMutex - lockPlayedCount sync.RWMutex - lockRemoveFavorite sync.RWMutex - lockUpdateHashLink sync.RWMutex - lockUpdateLength sync.RWMutex + lockAddFavorite sync.RWMutex + lockAddPlay sync.RWMutex + lockCreate sync.RWMutex + lockFavoriteCount sync.RWMutex + lockFavorites sync.RWMutex + lockFavoritesOf sync.RWMutex + lockFavoritesOfDatabase sync.RWMutex + lockFromHash sync.RWMutex + lockFromMetadata sync.RWMutex + lockLastPlayed sync.RWMutex + lockLastPlayedCount sync.RWMutex + lockLastPlayedPagination sync.RWMutex + lockPlayedCount sync.RWMutex + lockRemoveFavorite sync.RWMutex + lockUpdateHashLink sync.RWMutex + lockUpdateLength sync.RWMutex } // AddFavorite calls AddFavoriteFunc. @@ -3569,21 +3585,21 @@ func (mock *SongStorageMock) FromMetadataCalls() []struct { } // LastPlayed calls LastPlayedFunc. -func (mock *SongStorageMock) LastPlayed(offset int64, amount int64) ([]radio.Song, error) { +func (mock *SongStorageMock) LastPlayed(key radio.LastPlayedKey, amountPerPage int) ([]radio.Song, error) { if mock.LastPlayedFunc == nil { panic("SongStorageMock.LastPlayedFunc: method is nil but SongStorage.LastPlayed was just called") } callInfo := struct { - Offset int64 - Amount int64 + Key radio.LastPlayedKey + AmountPerPage int }{ - Offset: offset, - Amount: amount, + Key: key, + AmountPerPage: amountPerPage, } mock.lockLastPlayed.Lock() mock.calls.LastPlayed = append(mock.calls.LastPlayed, callInfo) mock.lockLastPlayed.Unlock() - return mock.LastPlayedFunc(offset, amount) + return mock.LastPlayedFunc(key, amountPerPage) } // LastPlayedCalls gets all the calls that were made to LastPlayed. @@ -3591,12 +3607,12 @@ func (mock *SongStorageMock) LastPlayed(offset int64, amount int64) ([]radio.Son // // len(mockedSongStorage.LastPlayedCalls()) func (mock *SongStorageMock) LastPlayedCalls() []struct { - Offset int64 - Amount int64 + Key radio.LastPlayedKey + AmountPerPage int } { var calls []struct { - Offset int64 - Amount int64 + Key radio.LastPlayedKey + AmountPerPage int } mock.lockLastPlayed.RLock() calls = mock.calls.LastPlayed @@ -3631,6 +3647,46 @@ func (mock *SongStorageMock) LastPlayedCountCalls() []struct { return calls } +// LastPlayedPagination calls LastPlayedPaginationFunc. +func (mock *SongStorageMock) LastPlayedPagination(key radio.LastPlayedKey, amountPerPage int, pageCount int) ([]radio.LastPlayedKey, []radio.LastPlayedKey, error) { + if mock.LastPlayedPaginationFunc == nil { + panic("SongStorageMock.LastPlayedPaginationFunc: method is nil but SongStorage.LastPlayedPagination was just called") + } + callInfo := struct { + Key radio.LastPlayedKey + AmountPerPage int + PageCount int + }{ + Key: key, + AmountPerPage: amountPerPage, + PageCount: pageCount, + } + mock.lockLastPlayedPagination.Lock() + mock.calls.LastPlayedPagination = append(mock.calls.LastPlayedPagination, callInfo) + mock.lockLastPlayedPagination.Unlock() + return mock.LastPlayedPaginationFunc(key, amountPerPage, pageCount) +} + +// LastPlayedPaginationCalls gets all the calls that were made to LastPlayedPagination. +// Check the length with: +// +// len(mockedSongStorage.LastPlayedPaginationCalls()) +func (mock *SongStorageMock) LastPlayedPaginationCalls() []struct { + Key radio.LastPlayedKey + AmountPerPage int + PageCount int +} { + var calls []struct { + Key radio.LastPlayedKey + AmountPerPage int + PageCount int + } + mock.lockLastPlayedPagination.RLock() + calls = mock.calls.LastPlayedPagination + mock.lockLastPlayedPagination.RUnlock() + return calls +} + // PlayedCount calls PlayedCountFunc. func (mock *SongStorageMock) PlayedCount(song radio.Song) (int64, error) { if mock.PlayedCountFunc == nil { diff --git a/radio.go b/radio.go index 717c057f..9da50328 100644 --- a/radio.go +++ b/radio.go @@ -804,6 +804,10 @@ type SongStorageService interface { SongTx(context.Context, StorageTx) (SongStorage, StorageTx, error) } +type LastPlayedKey uint32 + +const LPKeyLast = LastPlayedKey(math.MaxUint32) + // SongStorage stores information about songs // // A song can be anything that plays on stream, unlike a track which is a specific @@ -818,7 +822,9 @@ type SongStorage interface { // LastPlayed returns songs that have recently played, up to amount given after // applying the offset - LastPlayed(offset, amount int64) ([]Song, error) + LastPlayed(key LastPlayedKey, amountPerPage int) ([]Song, error) + // LastPlayedPagination looks up keys for adjacent pages of key + LastPlayedPagination(key LastPlayedKey, amountPerPage, pageCount int) (prev, next []LastPlayedKey, err error) // LastPlayedCount returns the amount of plays recorded LastPlayedCount() (int64, error) // PlayedCount returns the amount of times the song has been played on stream diff --git a/storage/mariadb/track.go b/storage/mariadb/track.go index 0b5ae75f..29791938 100644 --- a/storage/mariadb/track.go +++ b/storage/mariadb/track.go @@ -2,11 +2,13 @@ package mariadb import ( "database/sql" + "slices" "strings" "time" radio "github.com/R-a-dio/valkyrie" "github.com/R-a-dio/valkyrie/errors" + "github.com/R-a-dio/valkyrie/util" "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" ) @@ -234,20 +236,22 @@ LEFT JOIN users ON djs.id = users.djid LEFT JOIN themes ON djs.theme_id = themes.id +WHERE + eplay.id < ? ORDER BY eplay.dt DESC, eplay.id DESC -LIMIT ? OFFSET ?; +LIMIT ?; `) // LastPlayed implements radio.SongStorage -func (ss SongStorage) LastPlayed(offset, amount int64) ([]radio.Song, error) { +func (ss SongStorage) LastPlayed(key radio.LastPlayedKey, amountPerPage int) ([]radio.Song, error) { const op errors.Op = "mariadb/SongStorage.LastPlayed" handle, deferFn := ss.handle.span(op) defer deferFn() - var songs = make([]radio.Song, 0, amount) + var songs = make([]radio.Song, 0, amountPerPage) - err := sqlx.Select(handle, &songs, songLastPlayedQuery, amount, offset) + err := sqlx.Select(handle, &songs, songLastPlayedQuery, key, amountPerPage) if err != nil { return nil, errors.E(op, err) } @@ -264,6 +268,60 @@ func (ss SongStorage) LastPlayed(offset, amount int64) ([]radio.Song, error) { return songs, nil } +func (ss SongStorage) LastPlayedPagination(key radio.LastPlayedKey, amountPerPage, pageCount int) (prev, next []radio.LastPlayedKey, err error) { + const op errors.Op = "mariadb/SongStorage.LastPlayedPagination" + handle, deferFn := ss.handle.span(op) + defer deferFn() + + total := amountPerPage * pageCount + tmp := make([]radio.LastPlayedKey, 0, total) + + query := ` + SELECT + id + FROM + eplay + WHERE + id < ? + ORDER BY + dt DESC, id DESC + LIMIT ?; + ` + + err = sqlx.Select(handle, &tmp, query, key, total) + if err != nil { + return nil, nil, errors.E(op, err) + } + // reduce to just the page boundaries + next = util.ReduceWithStep(tmp, amountPerPage) + + // reset tmp for the next set + tmp = tmp[:0] + query = ` + SELECT + id + FROM + eplay + WHERE + id >= ? + ORDER BY + dt ASC, id ASC + LIMIT ?; + ` + + err = sqlx.Select(handle, &tmp, query, key, total) + if err != nil { + return nil, nil, errors.E(op, err) + } + + // reduce to just the page boundaries + prev = util.ReduceWithStep(tmp, amountPerPage) + // reverse since they're in ascending order + slices.Reverse(prev) + + return prev, next, nil +} + // LastPlayedCount implements radio.SongStorage func (ss SongStorage) LastPlayedCount() (int64, error) { const op errors.Op = "mariadb/SongStorage.LastPlayedCount" diff --git a/storage/test/track.go b/storage/test/track.go index c66c81e4..6639aebe 100644 --- a/storage/test/track.go +++ b/storage/test/track.go @@ -258,14 +258,13 @@ func (suite *Suite) TestSongLastPlayed(t *testing.T) { ID: 10, }, } - amount := int64(50) - + amount := 50 // create 50 testing songs var songs []radio.Song - for i := int64(0); i < amount; i++ { + for i := 0; i < amount; i++ { song := base song.Length = time.Duration(i*2) * time.Second - song.Metadata = song.Metadata + strconv.FormatInt(i, 10) + song.Metadata = song.Metadata + strconv.Itoa(i) song.Hydrate() new, err := ss.Create(song) @@ -288,10 +287,10 @@ func (suite *Suite) TestSongLastPlayed(t *testing.T) { n, err := ss.LastPlayedCount() require.NoError(t, err) - assert.Equal(t, amount, n) + assert.Equal(t, amount, int(n)) // test the full list of songs - lp, err := ss.LastPlayed(0, amount) + lp, err := ss.LastPlayed(radio.LPKeyLast, amount) require.NoError(t, err) // reverse them since we added them in 0-49 order but we will get them back as 49-0 order slices.Reverse(lp) @@ -308,20 +307,39 @@ func (suite *Suite) TestSongLastPlayed(t *testing.T) { } // test a subset of the list - lp, err = ss.LastPlayed(0, 20) + lp, err = ss.LastPlayed(radio.LPKeyLast, 20) require.NoError(t, err) slices.Reverse(lp) for i, original := range songs[amount-20 : amount] { assert.True(t, original.EqualTo(lp[i]), "subset start: expected %s got %s", original.Metadata, lp[i].Metadata) } + prev, _, err := ss.LastPlayedPagination(radio.LPKeyLast, 20, 5) + require.NoError(t, err) + // test the other end of the subset - lp, err = ss.LastPlayed(30, 20) + lp, err = ss.LastPlayed(prev[1], 20) require.NoError(t, err) slices.Reverse(lp) - for i, original := range songs[:20] { + + for i, original := range songs[:10] { assert.True(t, original.EqualTo(lp[i]), "subset end: expected %s got %s", original.Metadata, lp[i].Metadata) } + + // the below scenario is done by the irc bot, see if that is handled correctly + prev, _, err = ss.LastPlayedPagination(radio.LPKeyLast, 1, 50) + require.NoError(t, err) + + for index := range 20 { + key := radio.LPKeyLast + if index > 0 { + key = prev[index-1] + } + lp, err = ss.LastPlayed(key, 1) + require.NoError(t, err) + original := songs[len(songs)-1-index] + assert.True(t, original.EqualTo(lp[0]), "expected %s got %s", original, lp[0]) + } } func (suite *Suite) TestTrackUpdateMetadata(t *testing.T) { diff --git a/util/util.go b/util/util.go index bfb5ff44..a6582316 100644 --- a/util/util.go +++ b/util/util.go @@ -292,3 +292,20 @@ func RestoreOrListen(store *fdstore.Store, name string, network, addr string) (n return lns[0].Listener, lns[0].Data, nil } + +func ReduceWithStep[T any](s []T, step int) []T { + if step < 1 { + // set the step to 1 if it's lower than that, this to + // avoid a panic below, also zero or negative step is + // undefined behavior for this function + step = 1 + } + + var res []T + + for i := step - 1; i < len(s); i += step { + res = append(res, s[i]) + } + + return res +} diff --git a/util/util_test.go b/util/util_test.go index 4a04a64e..dad50252 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -3,6 +3,7 @@ package util import ( "net/http" "net/http/httptest" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -72,3 +73,72 @@ func TestAddContentDispositionSong(t *testing.T) { assert.Equal(t, `attachment; filename="hello - world.flac"; filename*=UTF-8''hello%20-%20world.flac`, value) assert.Equal(t, "audio/flac", w.Header().Get("Content-Type")) } + +func TestReduceWithStep(t *testing.T) { + var in []int + + for i := range 50 { + in = append(in, i) + } + + tests := []struct { + step int + expected []int + }{ + { + step: 10, + expected: []int{9, 19, 29, 39, 49}, + }, + { + step: 9, + expected: []int{8, 17, 26, 35, 44}, + }, + { + step: 8, + expected: []int{7, 15, 23, 31, 39, 47}, + }, + { + step: 7, + expected: []int{6, 13, 20, 27, 34, 41, 48}, + }, + { + step: 6, + expected: []int{5, 11, 17, 23, 29, 35, 41, 47}, + }, + { + step: 5, + expected: []int{4, 9, 14, 19, 24, 29, 34, 39, 44, 49}, + }, + { + step: 4, + expected: []int{3, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43, 47}, + }, + { + step: 3, + expected: []int{2, 5, 8, 11, 14, 17, 20, 23, 26, 29, 32, 35, 38, 41, 44, 47}, + }, + { + step: 2, + expected: []int{1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31, 33, 35, 37, 39, 41, 43, 45, 47, 49}, + }, + { + step: 1, + expected: in, + }, + { + step: -100, + expected: in, + }, + { + step: 0, + expected: in, + }, + } + + for _, test := range tests { + t.Run(strconv.Itoa(test.step), func(t *testing.T) { + out := ReduceWithStep(in, test.step) + assert.Equal(t, test.expected, out) + }) + } +} diff --git a/website/api/php/api.go b/website/api/php/api.go index 43c3793b..b080eaa4 100644 --- a/website/api/php/api.go +++ b/website/api/php/api.go @@ -555,7 +555,7 @@ func (s *v0Status) createStatusJSON(ctx context.Context) (v0StatusJSON, error) { } } - lp, err := s.songs.Song(ctx).LastPlayed(0, 5) + lp, err := s.songs.Song(ctx).LastPlayed(radio.LPKeyLast, 5) if err != nil { return last, err } diff --git a/website/api/v1/sse.go b/website/api/v1/sse.go index c0990fc1..07937836 100644 --- a/website/api/v1/sse.go +++ b/website/api/v1/sse.go @@ -74,7 +74,7 @@ func (a *API) sendQueue(ctx context.Context) { } func (a *API) sendLastPlayed(ctx context.Context) { - lp, err := a.storage.Song(ctx).LastPlayed(0, 5) + lp, err := a.storage.Song(ctx).LastPlayed(radio.LPKeyLast, 5) if err != nil { zerolog.Ctx(ctx).Error().Err(err).Str("sse", "lastplayed").Msg("") return diff --git a/website/public/home.go b/website/public/home.go index 3b29edec..be016b41 100644 --- a/website/public/home.go +++ b/website/public/home.go @@ -49,7 +49,7 @@ func (s State) getHome(w http.ResponseWriter, r *http.Request) error { } input.Queue = queue - lp, err := s.Storage.Song(ctx).LastPlayed(0, 5) + lp, err := s.Storage.Song(ctx).LastPlayed(radio.LPKeyLast, 5) if err != nil { return errors.E(op, errors.InternalServer, err) } diff --git a/website/public/lastplayed.go b/website/public/lastplayed.go index cf811049..3ef0017e 100644 --- a/website/public/lastplayed.go +++ b/website/public/lastplayed.go @@ -18,7 +18,7 @@ type LastPlayedInput struct { middleware.Input Songs []radio.Song - Page *shared.Pagination + Page *shared.FromPagination[radio.LastPlayedKey] } func (LastPlayedInput) TemplateBundle() string { @@ -28,29 +28,28 @@ func (LastPlayedInput) TemplateBundle() string { func NewLastPlayedInput(s radio.SongStorageService, r *http.Request) (*LastPlayedInput, error) { const op errors.Op = "website/public.NewLastPlayedInput" - page, offset, err := getPageOffset(r, lastplayedSize) + key, page, err := getPageFrom(r) if err != nil { return nil, errors.E(op, err) } ss := s.Song(r.Context()) - songs, err := ss.LastPlayed(offset, lastplayedSize) + songs, err := ss.LastPlayed(key, lastplayedSize) if err != nil { return nil, errors.E(op, err) } - total, err := ss.LastPlayedCount() + prev, next, err := ss.LastPlayedPagination(key, lastplayedSize, 5) if err != nil { return nil, errors.E(op, err) } + pagination := shared.NewFromPagination(key, prev, next, r.URL).WithPage(page) + return &LastPlayedInput{ Input: middleware.InputFromRequest(r), Songs: songs, - Page: shared.NewPagination( - page, shared.PageCount(total, lastplayedSize), - r.URL, - ), + Page: pagination, }, nil } @@ -71,6 +70,31 @@ func (s State) GetLastPlayed(w http.ResponseWriter, r *http.Request) { } } +func getPageFrom(r *http.Request) (radio.LastPlayedKey, int, error) { + var key = radio.LPKeyLast + var page int = 1 + + if rawPage := r.FormValue("page"); rawPage != "" { + parsedPage, err := strconv.Atoi(rawPage) + if err != nil { + return key, page, errors.E(err, errors.InvalidForm) + } + page = parsedPage + } + + rawFrom := r.FormValue("from") + if rawFrom == "" { + return key, page, nil + } + + parsedFrom, err := strconv.ParseUint(rawFrom, 10, 32) + if err != nil { + return key, page, errors.E(err, errors.InvalidForm) + } + key = radio.LastPlayedKey(parsedFrom) + + return key, page, nil +} func getPageOffset(r *http.Request, pageSize int64) (int64, int64, error) { var page int64 = 1 { diff --git a/website/shared/pagination.go b/website/shared/pagination.go index 6e413716..9e3b0154 100644 --- a/website/shared/pagination.go +++ b/website/shared/pagination.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/R-a-dio/valkyrie/errors" + "golang.org/x/exp/constraints" ) func PageCount(total, size int64) int64 { @@ -103,3 +104,176 @@ func (p *Pagination) Prev(offset int64) *Pagination { func (p *Pagination) Last() *Pagination { return p.createPage(p.Total) } + +func NewFromPagination[T constraints.Unsigned](key T, prev, next []T, uri *url.URL) *FromPagination[T] { + // construct a boundaries slices from the prev, key and next arguments + var boundaries []T + boundaries = append(boundaries, prev...) + index := len(boundaries) // record the index of where we are putting the key + boundaries = append(boundaries, key) + boundaries = append(boundaries, next...) + + return &FromPagination[T]{ + Key: key, + index: index, + boundaries: boundaries, + uri: uri, + } +} + +type FromPagination[T constraints.Unsigned] struct { + Key T + Nr int + + index int + boundaries []T + uri *url.URL + + // fields to implement Last() + last *T + lastNr int +} + +const FromTimeFormat = "2006-01-02T15-04-05" + +func (p *FromPagination[T]) URL() template.URL { + if p == nil || p.uri == nil { + return template.URL("") + } + + u := *p.uri + v := u.Query() + v.Set("from", strconv.FormatUint(uint64(p.Key), 10)) + v.Set("page", strconv.FormatInt(int64(p.Nr), 10)) + u.RawQuery = v.Encode() + return template.URL(u.RequestURI()) +} + +func (p *FromPagination[T]) BaseURL() template.URL { + if p == nil || p.uri == nil { + return template.URL("") + } + return template.URL(p.uri.Path) +} + +// First returns the first page, this uses time.Now() as the Key and +// 1 as the page number. +func (p *FromPagination[T]) First() *FromPagination[T] { + if p == nil { + return nil + } + + return &FromPagination[T]{ + Key: maxOf[T](), + Nr: 1, + uri: p.uri, + last: p.last, + lastNr: p.lastNr, + } +} + +// Next returns the next page as indicated by the offset from current +func (p *FromPagination[T]) Next(offset int) *FromPagination[T] { + if p == nil { + return nil + } + + index := p.index + offset + if index >= len(p.boundaries) || index < 0 { + // index out of range after applying offset, return no page + return nil + } + + return &FromPagination[T]{ + Key: p.boundaries[index], + Nr: p.Nr + offset, + index: index, + boundaries: p.boundaries, + uri: p.uri, + last: p.last, + lastNr: p.lastNr, + } +} + +// Prev returns the previous page as indicated by the offset from current +func (p *FromPagination[T]) Prev(offset int) *FromPagination[T] { + if p == nil { + return nil + } + + index := p.index - offset + if index < 0 || index >= len(p.boundaries) { + // index out of range after applying offset, return no page + return nil + } + + return &FromPagination[T]{ + Key: p.boundaries[index], + Nr: p.Nr - offset, + index: index, + boundaries: p.boundaries, + uri: p.uri, + last: p.last, + lastNr: p.lastNr, + } +} + +// WithPage returns a page with the page number set to nr +func (p *FromPagination[T]) WithPage(nr int) *FromPagination[T] { + if p == nil { + return nil + } + + n := *p + n.Nr = nr + return &n +} + +// WithLast returns a page with the last page info set to key and nr +func (p *FromPagination[T]) WithLast(key T, nr int) *FromPagination[T] { + if p == nil { + return nil + } + + n := *p + n.last = &key + n.lastNr = nr + return &n +} + +// Last returns the last page, will return nil if WithLast wasn't called beforehand +// on a parent +func (p *FromPagination[T]) Last() *FromPagination[T] { + if p.last == nil { + return nil + } + // we don't have information on what the last page is + return &FromPagination[T]{ + Key: *p.last, + Nr: p.lastNr, + uri: p.uri, + last: p.last, + } +} + +func sizeOf[T constraints.Integer]() uint { + x := uint16(1 << 8) + y := uint32(2 << 16) + z := uint64(4 << 32) + return 1 + uint(T(x))>>8 + uint(T(y))>>16 + uint(T(z))>>32 +} + +func minOf[T constraints.Integer]() T { + if ones := ^T(0); ones < 0 { + return ones << (8*sizeOf[T]() - 1) + } + return 0 +} + +func maxOf[T constraints.Integer]() T { + ones := ^T(0) + if ones < 0 { + return ones ^ (ones << (8*sizeOf[T]() - 1)) + } + return ones +} diff --git a/website/shared/pagination_test.go b/website/shared/pagination_test.go new file mode 100644 index 00000000..68459e26 --- /dev/null +++ b/website/shared/pagination_test.go @@ -0,0 +1,108 @@ +package shared + +import ( + "math" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFromPagination(t *testing.T) { + uri, err := url.Parse("http://example.org/?from=test") + require.NoError(t, err) + + now := uint(80) + prev := []uint{ + now - 5, + now - 4, + now - 3, + now - 2, + now - 1, + } + next := []uint{ + now + 1, + now + 2, + now + 3, + now + 4, + now + 5, + } + + p := NewFromPagination(now, prev, next, uri) + + t.Run("InitialIndex", func(t *testing.T) { + assert.Equal(t, now, p.boundaries[p.index]) + }) + t.Run("Current/URL", func(t *testing.T) { + + }) + t.Run("Prev", func(t *testing.T) { + p := p.Prev(1) + assert.Equal(t, prev[4], p.boundaries[p.index]) + p = p.Prev(1) + assert.Equal(t, prev[3], p.boundaries[p.index]) + p = p.Prev(1) + assert.Equal(t, prev[2], p.boundaries[p.index]) + p = p.Prev(1) + assert.Equal(t, prev[1], p.boundaries[p.index]) + p = p.Prev(1) + assert.Equal(t, prev[0], p.boundaries[p.index]) + p = p.Prev(1) // prev too far should nil + assert.Nil(t, p) + p = p.Prev(1) // prev on nil should nil + assert.Nil(t, p) + }) + + t.Run("Next", func(t *testing.T) { + p := p.Next(1) + assert.Equal(t, next[0], p.boundaries[p.index]) + p = p.Next(1) + assert.Equal(t, next[1], p.boundaries[p.index]) + p = p.Next(1) + assert.Equal(t, next[2], p.boundaries[p.index]) + p = p.Next(1) + assert.Equal(t, next[3], p.boundaries[p.index]) + p = p.Next(1) + assert.Equal(t, next[4], p.boundaries[p.index]) + p = p.Next(1) // next too far should nil + assert.Nil(t, p) + p = p.Next(1) // next on nil should nil + assert.Nil(t, p) + }) + + t.Run("NextPrev", func(t *testing.T) { + p := p.Next(3) + assert.Equal(t, next[2], p.Key) + p = p.Prev(6) + assert.Equal(t, prev[2], p.Key) + p = p.Next(16) + assert.Nil(t, p) + }) + + t.Run("First", func(t *testing.T) { + p := p.First() + require.NotNil(t, p) + assert.Equal(t, uint(math.MaxUint), p.Key) + p = nil + p = p.First() + assert.Nil(t, p) + }) + + t.Run("NilLast", func(t *testing.T) { + p := p.Last() + assert.Nil(t, p) + p.WithLast(0, 50) + }) + + last := uint(5) + lastNr := 7 + p = p.WithLast(last, lastNr) + + t.Run("Last", func(t *testing.T) { + p := p.Last() + require.NotNil(t, p) + assert.Equal(t, last, p.Key) + assert.Equal(t, lastNr, p.Nr) + }) +}