summaryrefslogtreecommitdiff
path: root/internal/processing/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'internal/processing/timeline')
-rw-r--r--internal/processing/timeline/common.go71
-rw-r--r--internal/processing/timeline/faved.go1
-rw-r--r--internal/processing/timeline/home.go189
-rw-r--r--internal/processing/timeline/home_test.go54
-rw-r--r--internal/processing/timeline/list.go202
-rw-r--r--internal/processing/timeline/notification.go1
-rw-r--r--internal/processing/timeline/public.go243
-rw-r--r--internal/processing/timeline/public_test.go45
-rw-r--r--internal/processing/timeline/tag.go140
-rw-r--r--internal/processing/timeline/timeline.go135
10 files changed, 491 insertions, 590 deletions
diff --git a/internal/processing/timeline/common.go b/internal/processing/timeline/common.go
deleted file mode 100644
index 6d29d81d6..000000000
--- a/internal/processing/timeline/common.go
+++ /dev/null
@@ -1,71 +0,0 @@
-// GoToSocial
-// Copyright (C) GoToSocial Authors admin@gotosocial.org
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-package timeline
-
-import (
- "context"
-
- "github.com/superseriousbusiness/gotosocial/internal/timeline"
-)
-
-// SkipInsert returns a function that satisifes SkipInsertFunction.
-func SkipInsert() timeline.SkipInsertFunction {
- // Gap to allow between a status or boost of status,
- // and reinsertion of a new boost of that status.
- // This is useful to avoid a heavily boosted status
- // showing up way too often in a user's timeline.
- const boostReinsertionDepth = 50
-
- return func(
- ctx context.Context,
- newItemID string,
- newItemAccountID string,
- newItemBoostOfID string,
- newItemBoostOfAccountID string,
- nextItemID string,
- nextItemAccountID string,
- nextItemBoostOfID string,
- nextItemBoostOfAccountID string,
- depth int,
- ) (bool, error) {
- if newItemID == nextItemID {
- // Don't insert duplicates.
- return true, nil
- }
-
- if newItemBoostOfID != "" {
- if newItemBoostOfID == nextItemBoostOfID &&
- depth < boostReinsertionDepth {
- // Don't insert boosts of items
- // we've seen boosted recently.
- return true, nil
- }
-
- if newItemBoostOfID == nextItemID &&
- depth < boostReinsertionDepth {
- // Don't insert boosts of items when
- // we've seen the original recently.
- return true, nil
- }
- }
-
- // Proceed with insertion
- // (that's what she said!).
- return false, nil
- }
-}
diff --git a/internal/processing/timeline/faved.go b/internal/processing/timeline/faved.go
index 6e915f4ef..bdafcac36 100644
--- a/internal/processing/timeline/faved.go
+++ b/internal/processing/timeline/faved.go
@@ -31,6 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/util"
)
+// FavedTimelineGet ...
func (p *Processor) FavedTimelineGet(ctx context.Context, authed *apiutil.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
statuses, nextMaxID, prevMinID, err := p.state.DB.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
diff --git a/internal/processing/timeline/home.go b/internal/processing/timeline/home.go
index 38cf38405..61fef005b 100644
--- a/internal/processing/timeline/home.go
+++ b/internal/processing/timeline/home.go
@@ -19,132 +19,85 @@ package timeline
import (
"context"
- "errors"
+ "net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
- apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
- "github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
- "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
- "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
- "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/state"
- "github.com/superseriousbusiness/gotosocial/internal/timeline"
- "github.com/superseriousbusiness/gotosocial/internal/typeutils"
- "github.com/superseriousbusiness/gotosocial/internal/util"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
)
-// HomeTimelineGrab returns a function that satisfies GrabFunction for home timelines.
-func HomeTimelineGrab(state *state.State) timeline.GrabFunction {
- return func(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
- statuses, err := state.DB.GetHomeTimeline(ctx, accountID, maxID, sinceID, minID, limit, false)
- if err != nil && !errors.Is(err, db.ErrNoEntries) {
- err = gtserror.Newf("error getting statuses from db: %w", err)
- return nil, false, err
+// HomeTimelineGet gets a pageable timeline of statuses
+// in the home timeline of the requesting account.
+func (p *Processor) HomeTimelineGet(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ page *paging.Page,
+ local bool,
+) (
+ *apimodel.PageableResponse,
+ gtserror.WithCode,
+) {
+
+ var pageQuery url.Values
+ var postFilter func(*gtsmodel.Status) bool
+ if local {
+ // Set local = true query.
+ pageQuery = localOnlyTrue
+ postFilter = func(s *gtsmodel.Status) bool {
+ return !*s.Local
}
-
- count := len(statuses)
- if count == 0 {
- // We just don't have enough statuses
- // left in the db so return stop = true.
- return nil, true, nil
- }
-
- items := make([]timeline.Timelineable, count)
- for i, s := range statuses {
- items[i] = s
- }
-
- return items, false, nil
- }
-}
-
-// HomeTimelineFilter returns a function that satisfies FilterFunction for home timelines.
-func HomeTimelineFilter(state *state.State, visFilter *visibility.Filter) timeline.FilterFunction {
- return func(ctx context.Context, accountID string, item timeline.Timelineable) (shouldIndex bool, err error) {
- status, ok := item.(*gtsmodel.Status)
- if !ok {
- err = gtserror.New("could not convert item to *gtsmodel.Status")
- return false, err
- }
-
- requestingAccount, err := state.DB.GetAccountByID(ctx, accountID)
- if err != nil {
- err = gtserror.Newf("error getting account with id %s: %w", accountID, err)
- return false, err
- }
-
- timelineable, err := visFilter.StatusHomeTimelineable(ctx, requestingAccount, status)
- if err != nil {
- err = gtserror.Newf("error checking hometimelineability of status %s for account %s: %w", status.ID, accountID, err)
- return false, err
- }
-
- return timelineable, nil
- }
-}
-
-// HomeTimelineStatusPrepare returns a function that satisfies PrepareFunction for home timelines.
-func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converter) timeline.PrepareFunction {
- return func(ctx context.Context, accountID string, itemID string) (timeline.Preparable, error) {
- status, err := state.DB.GetStatusByID(ctx, itemID)
- if err != nil {
- err = gtserror.Newf("error getting status with id %s: %w", itemID, err)
- return nil, err
- }
-
- requestingAccount, err := state.DB.GetAccountByID(ctx, accountID)
- if err != nil {
- err = gtserror.Newf("error getting account with id %s: %w", accountID, err)
- return nil, err
- }
-
- filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
- if err != nil {
- err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
- return nil, err
- }
-
- mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
- if err != nil {
- err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
- return nil, err
- }
- compiledMutes := usermute.NewCompiledUserMuteList(mutes)
-
- return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes)
+ } else {
+ // Set local = false query.
+ pageQuery = localOnlyFalse
+ postFilter = nil
}
-}
-
-func (p *Processor) HomeTimelineGet(ctx context.Context, authed *apiutil.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
- statuses, err := p.state.Timelines.Home.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
- if err != nil && !errors.Is(err, db.ErrNoEntries) {
- err = gtserror.Newf("error getting statuses: %w", err)
- return nil, gtserror.NewErrorInternalError(err)
- }
-
- count := len(statuses)
- if count == 0 {
- return util.EmptyPageableResponse(), nil
- }
-
- var (
- items = make([]interface{}, count)
- nextMaxIDValue = statuses[count-1].GetID()
- prevMinIDValue = statuses[0].GetID()
+ return p.getStatusTimeline(ctx,
+
+ // Auth'd
+ // account.
+ requester,
+
+ // Keyed-by-account-ID, home timeline cache.
+ p.state.Caches.Timelines.Home.MustGet(requester.ID),
+
+ // Current
+ // page.
+ page,
+
+ // Home timeline endpoint.
+ "/api/v1/timelines/home",
+
+ // Set local-only timeline
+ // page query flag, (this map
+ // later gets copied before
+ // any further usage).
+ pageQuery,
+
+ // Status filter context.
+ statusfilter.FilterContextHome,
+
+ // Database load function.
+ func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
+ return p.state.DB.GetHomeTimeline(ctx, requester.ID, pg)
+ },
+
+ // Filtering function,
+ // i.e. filter before caching.
+ func(s *gtsmodel.Status) bool {
+
+ // Check the visibility of passed status to requesting user.
+ ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s)
+ if err != nil {
+ log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
+ }
+ return !ok
+ },
+
+ // Post filtering funtion,
+ // i.e. filter after caching.
+ postFilter,
)
-
- for i := range statuses {
- items[i] = statuses[i]
- }
-
- return util.PackagePageableResponse(util.PageableResponseParams{
- Items: items,
- Path: "/api/v1/timelines/home",
- NextMaxIDValue: nextMaxIDValue,
- PrevMinIDValue: prevMinIDValue,
- Limit: limit,
- })
}
diff --git a/internal/processing/timeline/home_test.go b/internal/processing/timeline/home_test.go
index ea56418f6..50025b9a8 100644
--- a/internal/processing/timeline/home_test.go
+++ b/internal/processing/timeline/home_test.go
@@ -23,13 +23,9 @@ import (
"github.com/stretchr/testify/suite"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
- apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
- "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
- tlprocessor "github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
- "github.com/superseriousbusiness/gotosocial/internal/timeline"
- "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@@ -37,25 +33,7 @@ type HomeTestSuite struct {
TimelineStandardTestSuite
}
-func (suite *HomeTestSuite) SetupTest() {
- suite.TimelineStandardTestSuite.SetupTest()
-
- suite.state.Timelines.Home = timeline.NewManager(
- tlprocessor.HomeTimelineGrab(&suite.state),
- tlprocessor.HomeTimelineFilter(&suite.state, visibility.NewFilter(&suite.state)),
- tlprocessor.HomeTimelineStatusPrepare(&suite.state, typeutils.NewConverter(&suite.state)),
- tlprocessor.SkipInsert(),
- )
- if err := suite.state.Timelines.Home.Start(); err != nil {
- suite.FailNow(err.Error())
- }
-}
-
func (suite *HomeTestSuite) TearDownTest() {
- if err := suite.state.Timelines.Home.Stop(); err != nil {
- suite.FailNow(err.Error())
- }
-
suite.TimelineStandardTestSuite.TearDownTest()
}
@@ -64,7 +42,6 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
var (
ctx = context.Background()
requester = suite.testAccounts["local_account_1"]
- authed = &apiutil.Auth{Account: requester}
maxID = ""
sinceID = ""
minID = "01F8MHAAY43M6RJ473VQFCVH36" // 1 before filteredStatus
@@ -97,11 +74,12 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
// Fetch the timeline to make sure the status we're going to filter is in that section of it.
resp, errWithCode := suite.timeline.HomeTimelineGet(
ctx,
- authed,
- maxID,
- sinceID,
- minID,
- limit,
+ requester,
+ &paging.Page{
+ Min: paging.EitherMinID(minID, sinceID),
+ Max: paging.MaxID(maxID),
+ Limit: limit,
+ },
local,
)
suite.NoError(errWithCode)
@@ -114,10 +92,9 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
if !filteredStatusFound {
suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline")
}
- // Prune the timeline to drop cached prepared statuses, a side effect of this precondition check.
- if _, err := suite.state.Timelines.Home.Prune(ctx, requester.ID, 0, 0); err != nil {
- suite.FailNow(err.Error())
- }
+
+ // Clear the timeline to drop all cached statuses.
+ suite.state.Caches.Timelines.Home.Clear(requester.ID)
// Create a filter to hide one status on the timeline.
if err := suite.db.PutFilter(ctx, filter); err != nil {
@@ -127,11 +104,12 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
// Fetch the timeline again with the filter in place.
resp, errWithCode = suite.timeline.HomeTimelineGet(
ctx,
- authed,
- maxID,
- sinceID,
- minID,
- limit,
+ requester,
+ &paging.Page{
+ Min: paging.EitherMinID(minID, sinceID),
+ Max: paging.MaxID(maxID),
+ Limit: limit,
+ },
local,
)
diff --git a/internal/processing/timeline/list.go b/internal/processing/timeline/list.go
index 147f87ab4..10a7bb388 100644
--- a/internal/processing/timeline/list.go
+++ b/internal/processing/timeline/list.go
@@ -22,155 +22,93 @@ import (
"errors"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
- apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
- "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
- "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/state"
- "github.com/superseriousbusiness/gotosocial/internal/timeline"
- "github.com/superseriousbusiness/gotosocial/internal/typeutils"
- "github.com/superseriousbusiness/gotosocial/internal/util"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
)
-// ListTimelineGrab returns a function that satisfies GrabFunction for list timelines.
-func ListTimelineGrab(state *state.State) timeline.GrabFunction {
- return func(ctx context.Context, listID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
- statuses, err := state.DB.GetListTimeline(ctx, listID, maxID, sinceID, minID, limit)
- if err != nil && !errors.Is(err, db.ErrNoEntries) {
- err = gtserror.Newf("error getting statuses from db: %w", err)
- return nil, false, err
- }
-
- count := len(statuses)
- if count == 0 {
- // We just don't have enough statuses
- // left in the db so return stop = true.
- return nil, true, nil
- }
-
- items := make([]timeline.Timelineable, count)
- for i, s := range statuses {
- items[i] = s
- }
-
- return items, false, nil
+// ListTimelineGet gets a pageable timeline of statuses
+// in the list timeline of ID by the requesting account.
+func (p *Processor) ListTimelineGet(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ listID string,
+ page *paging.Page,
+) (
+ *apimodel.PageableResponse,
+ gtserror.WithCode,
+) {
+ // Fetch the requested list with ID.
+ list, err := p.state.DB.GetListByID(
+ gtscontext.SetBarebones(ctx),
+ listID,
+ )
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorInternalError(err)
}
-}
-// ListTimelineFilter returns a function that satisfies FilterFunction for list timelines.
-func ListTimelineFilter(state *state.State, visFilter *visibility.Filter) timeline.FilterFunction {
- return func(ctx context.Context, listID string, item timeline.Timelineable) (shouldIndex bool, err error) {
- status, ok := item.(*gtsmodel.Status)
- if !ok {
- err = gtserror.New("could not convert item to *gtsmodel.Status")
- return false, err
- }
-
- list, err := state.DB.GetListByID(ctx, listID)
- if err != nil {
- err = gtserror.Newf("error getting list with id %s: %w", listID, err)
- return false, err
- }
-
- requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID)
- if err != nil {
- err = gtserror.Newf("error getting account with id %s: %w", list.AccountID, err)
- return false, err
- }
-
- timelineable, err := visFilter.StatusHomeTimelineable(ctx, requestingAccount, status)
- if err != nil {
- err = gtserror.Newf("error checking hometimelineability of status %s for account %s: %w", status.ID, list.AccountID, err)
- return false, err
- }
-
- return timelineable, nil
+ // Check exists.
+ if list == nil {
+ const text = "list not found"
+ return nil, gtserror.NewErrorNotFound(
+ errors.New(text),
+ text,
+ )
}
-}
-// ListTimelineStatusPrepare returns a function that satisfies PrepareFunction for list timelines.
-func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converter) timeline.PrepareFunction {
- return func(ctx context.Context, listID string, itemID string) (timeline.Preparable, error) {
- status, err := state.DB.GetStatusByID(ctx, itemID)
- if err != nil {
- err = gtserror.Newf("error getting status with id %s: %w", itemID, err)
- return nil, err
- }
-
- list, err := state.DB.GetListByID(ctx, listID)
- if err != nil {
- err = gtserror.Newf("error getting list with id %s: %w", listID, err)
- return nil, err
- }
-
- requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID)
- if err != nil {
- err = gtserror.Newf("error getting account with id %s: %w", list.AccountID, err)
- return nil, err
- }
-
- filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
- if err != nil {
- err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
- return nil, err
- }
-
- mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
- if err != nil {
- err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
- return nil, err
- }
- compiledMutes := usermute.NewCompiledUserMuteList(mutes)
-
- return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes)
+ // Check list owned by auth'd account.
+ if list.AccountID != requester.ID {
+ err := gtserror.New("list does not belong to account")
+ return nil, gtserror.NewErrorNotFound(err)
}
-}
-func (p *Processor) ListTimelineGet(ctx context.Context, authed *apiutil.Auth, listID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
- // Ensure list exists + is owned by this account.
- list, err := p.state.DB.GetListByID(ctx, listID)
- if err != nil {
- if errors.Is(err, db.ErrNoEntries) {
- return nil, gtserror.NewErrorNotFound(err)
- }
- return nil, gtserror.NewErrorInternalError(err)
- }
+ // Fetch status timeline for list.
+ return p.getStatusTimeline(ctx,
- if list.AccountID != authed.Account.ID {
- err = gtserror.Newf("list with id %s does not belong to account %s", list.ID, authed.Account.ID)
- return nil, gtserror.NewErrorNotFound(err)
- }
+ // Auth'd
+ // account.
+ requester,
- statuses, err := p.state.Timelines.List.GetTimeline(ctx, listID, maxID, sinceID, minID, limit, false)
- if err != nil && !errors.Is(err, db.ErrNoEntries) {
- err = gtserror.Newf("error getting statuses: %w", err)
- return nil, gtserror.NewErrorInternalError(err)
- }
+ // Keyed-by-list-ID, list timeline cache.
+ p.state.Caches.Timelines.List.MustGet(listID),
- count := len(statuses)
- if count == 0 {
- return util.EmptyPageableResponse(), nil
- }
+ // Current
+ // page.
+ page,
- var (
- items = make([]interface{}, count)
- nextMaxIDValue = statuses[count-1].GetID()
- prevMinIDValue = statuses[0].GetID()
- )
+ // List timeline ID's endpoint.
+ "/api/v1/timelines/list/"+listID,
- for i := range statuses {
- items[i] = statuses[i]
- }
+ // No page
+ // query.
+ nil,
- return util.PackagePageableResponse(util.PageableResponseParams{
- Items: items,
- Path: "/api/v1/timelines/list/" + listID,
- NextMaxIDValue: nextMaxIDValue,
- PrevMinIDValue: prevMinIDValue,
- Limit: limit,
- })
+ // Status filter context.
+ statusfilter.FilterContextHome,
+
+ // Database load function.
+ func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
+ return p.state.DB.GetListTimeline(ctx, listID, pg)
+ },
+
+ // Filtering function,
+ // i.e. filter before caching.
+ func(s *gtsmodel.Status) bool {
+
+ // Check the visibility of passed status to requesting user.
+ ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s)
+ if err != nil {
+ log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
+ }
+ return !ok
+ },
+
+ // Post filtering funtion,
+ // i.e. filter after caching.
+ nil,
+ )
}
diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go
index 04a898198..ba1e3dba8 100644
--- a/internal/processing/timeline/notification.go
+++ b/internal/processing/timeline/notification.go
@@ -36,6 +36,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/util"
)
+// NotificationsGet ...
func (p *Processor) NotificationsGet(
ctx context.Context,
authed *apiutil.Auth,
diff --git a/internal/processing/timeline/public.go b/internal/processing/timeline/public.go
index dc00688e3..0e675da14 100644
--- a/internal/processing/timeline/public.go
+++ b/internal/processing/timeline/public.go
@@ -19,152 +19,143 @@ package timeline
import (
"context"
- "errors"
- "strconv"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
- "github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
- "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
- "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
- "github.com/superseriousbusiness/gotosocial/internal/util"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
)
+// PublicTimelineGet gets a pageable timeline of public statuses
+// for the given requesting account. It ensures that each status
+// in timeline is visible to the account before returning it.
+//
+// The local argument limits this to local-only statuses.
func (p *Processor) PublicTimelineGet(
ctx context.Context,
requester *gtsmodel.Account,
- maxID string,
- sinceID string,
- minID string,
- limit int,
+ page *paging.Page,
local bool,
-) (*apimodel.PageableResponse, gtserror.WithCode) {
- const maxAttempts = 3
- var (
- nextMaxIDValue string
- prevMinIDValue string
- items = make([]any, 0, limit)
- )
-
- var filters []*gtsmodel.Filter
- var compiledMutes *usermute.CompiledUserMuteList
- if requester != nil {
- var err error
- filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID)
- if err != nil {
- err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err)
- return nil, gtserror.NewErrorInternalError(err)
- }
-
- mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requester.ID, nil)
- if err != nil {
- err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requester.ID, err)
- return nil, gtserror.NewErrorInternalError(err)
- }
- compiledMutes = usermute.NewCompiledUserMuteList(mutes)
+) (
+ *apimodel.PageableResponse,
+ gtserror.WithCode,
+) {
+ if local {
+ return p.localTimelineGet(ctx, requester, page)
}
+ return p.publicTimelineGet(ctx, requester, page)
+}
- // Try a few times to select appropriate public
- // statuses from the db, paging up or down to
- // reattempt if nothing suitable is found.
-outer:
- for attempts := 1; ; attempts++ {
- // Select slightly more than the limit to try to avoid situations where
- // we filter out all the entries, and have to make another db call.
- // It's cheaper to select more in 1 query than it is to do multiple queries.
- statuses, err := p.state.DB.GetPublicTimeline(ctx, maxID, sinceID, minID, limit+5, local)
- if err != nil && !errors.Is(err, db.ErrNoEntries) {
- err = gtserror.Newf("db error getting statuses: %w", err)
- return nil, gtserror.NewErrorInternalError(err)
- }
-
- count := len(statuses)
- if count == 0 {
- // Nothing relevant (left) in the db.
- return util.EmptyPageableResponse(), nil
- }
-
- // Page up from first status in slice
- // (ie., one with the highest ID).
- prevMinIDValue = statuses[0].ID
-
- inner:
- for _, s := range statuses {
- // Push back the next page down ID to
- // this status, regardless of whether
- // we end up filtering it out or not.
- nextMaxIDValue = s.ID
-
- timelineable, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
- if err != nil {
- log.Errorf(ctx, "error checking status visibility: %v", err)
- continue inner
- }
+func (p *Processor) publicTimelineGet(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ page *paging.Page,
+) (
+ *apimodel.PageableResponse,
+ gtserror.WithCode,
+) {
+ return p.getStatusTimeline(ctx,
+
+ // Auth acconut,
+ // can be nil.
+ requester,
+
+ // No cache.
+ nil,
+
+ // Current
+ // page.
+ page,
+
+ // Public timeline endpoint.
+ "/api/v1/timelines/public",
+
+ // Set local-only timeline
+ // page query flag, (this map
+ // later gets copied before
+ // any further usage).
+ localOnlyFalse,
+
+ // Status filter context.
+ statusfilter.FilterContextPublic,
+
+ // Database load function.
+ func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
+ return p.state.DB.GetPublicTimeline(ctx, pg)
+ },
- if !timelineable {
- continue inner
- }
+ // Pre-filtering function,
+ // i.e. filter before caching.
+ func(s *gtsmodel.Status) bool {
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters, compiledMutes)
- if errors.Is(err, statusfilter.ErrHideStatus) {
- continue
- }
+ // Check the visibility of passed status to requesting user.
+ ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
if err != nil {
- log.Errorf(ctx, "error converting to api status: %v", err)
- continue inner
+ log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
}
+ return !ok
+ },
- // Looks good, add this.
- items = append(items, apiStatus)
+ // Post filtering funtion,
+ // i.e. filter after caching.
+ nil,
+ )
+}
- // We called the db with a little
- // more than the desired limit.
- //
- // Ensure we don't return more
- // than the caller asked for.
- if len(items) == limit {
- break outer
- }
- }
-
- if len(items) != 0 {
- // We've got some items left after
- // filtering, happily break + return.
- break
- }
-
- if attempts >= maxAttempts {
- // We reached our attempts limit.
- // Be nice + warn about it.
- log.Warn(ctx, "reached max attempts to find items in public timeline")
- break
- }
-
- // We filtered out all items before we
- // found anything we could return, but
- // we still have attempts left to try
- // fetching again. Set paging params
- // and allow loop to continue.
- if minID != "" {
- // Paging up.
- minID = prevMinIDValue
- } else {
- // Paging down.
- maxID = nextMaxIDValue
- }
- }
+func (p *Processor) localTimelineGet(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ page *paging.Page,
+) (
+ *apimodel.PageableResponse,
+ gtserror.WithCode,
+) {
+ return p.getStatusTimeline(ctx,
+
+ // Auth acconut,
+ // can be nil.
+ requester,
+
+ // No cache.
+ nil,
+
+ // Current
+ // page.
+ page,
+
+ // Public timeline endpoint.
+ "/api/v1/timelines/public",
+
+ // Set local-only timeline
+ // page query flag, (this map
+ // later gets copied before
+ // any further usage).
+ localOnlyTrue,
+
+ // Status filter context.
+ statusfilter.FilterContextPublic,
+
+ // Database load function.
+ func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
+ return p.state.DB.GetLocalTimeline(ctx, pg)
+ },
+
+ // Filtering function,
+ // i.e. filter before caching.
+ func(s *gtsmodel.Status) bool {
- return util.PackagePageableResponse(util.PageableResponseParams{
- Items: items,
- Path: "/api/v1/timelines/public",
- NextMaxIDValue: nextMaxIDValue,
- PrevMinIDValue: prevMinIDValue,
- Limit: limit,
- ExtraQueryParams: []string{
- "local=" + strconv.FormatBool(local),
+ // Check the visibility of passed status to requesting user.
+ ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
+ if err != nil {
+ log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
+ }
+ return !ok
},
- })
+
+ // Post filtering funtion,
+ // i.e. filter after caching.
+ nil,
+ )
}
diff --git a/internal/processing/timeline/public_test.go b/internal/processing/timeline/public_test.go
index ab8e33429..b5017af71 100644
--- a/internal/processing/timeline/public_test.go
+++ b/internal/processing/timeline/public_test.go
@@ -25,6 +25,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@@ -46,10 +47,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGet() {
resp, errWithCode := suite.timeline.PublicTimelineGet(
ctx,
requester,
- maxID,
- sinceID,
- minID,
- limit,
+ &paging.Page{
+ Min: paging.EitherMinID(minID, sinceID),
+ Max: paging.MaxID(maxID),
+ Limit: limit,
+ },
local,
)
@@ -79,10 +81,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() {
resp, errWithCode := suite.timeline.PublicTimelineGet(
ctx,
requester,
- maxID,
- sinceID,
- minID,
- limit,
+ &paging.Page{
+ Min: paging.EitherMinID(minID, sinceID),
+ Max: paging.MaxID(maxID),
+ Limit: limit,
+ },
local,
)
@@ -90,9 +93,9 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() {
// some other statuses were filtered out.
suite.NoError(errWithCode)
suite.Len(resp.Items, 1)
- suite.Equal(`<http://localhost:8080/api/v1/timelines/public?limit=1&max_id=01F8MHCP5P2NWYQ416SBA0XSEV&local=false>; rel="next", <http://localhost:8080/api/v1/timelines/public?limit=1&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5&local=false>; rel="prev"`, resp.LinkHeader)
- suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&max_id=01F8MHCP5P2NWYQ416SBA0XSEV&local=false`, resp.NextLink)
- suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5&local=false`, resp.PrevLink)
+ suite.Equal(`<http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV>; rel="next", <http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5>; rel="prev"`, resp.LinkHeader)
+ suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV`, resp.NextLink)
+ suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5`, resp.PrevLink)
}
// A timeline containing a status hidden due to filtering should return other statuses with no error.
@@ -133,10 +136,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
resp, errWithCode := suite.timeline.PublicTimelineGet(
ctx,
requester,
- maxID,
- sinceID,
- minID,
- limit,
+ &paging.Page{
+ Min: paging.EitherMinID(minID, sinceID),
+ Max: paging.MaxID(maxID),
+ Limit: limit,
+ },
local,
)
suite.NoError(errWithCode)
@@ -149,8 +153,6 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
if !filteredStatusFound {
suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline")
}
- // The public timeline has no prepared status cache and doesn't need to be pruned,
- // as in the home timeline version of this test.
// Create a filter to hide one status on the timeline.
if err := suite.db.PutFilter(ctx, filter); err != nil {
@@ -161,10 +163,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
resp, errWithCode = suite.timeline.PublicTimelineGet(
ctx,
requester,
- maxID,
- sinceID,
- minID,
- limit,
+ &paging.Page{
+ Min: paging.EitherMinID(minID, sinceID),
+ Max: paging.MaxID(maxID),
+ Limit: limit,
+ },
local,
)
diff --git a/internal/processing/timeline/tag.go b/internal/processing/timeline/tag.go
index 811d0bb33..685bac376 100644
--- a/internal/processing/timeline/tag.go
+++ b/internal/processing/timeline/tag.go
@@ -20,18 +20,16 @@ package timeline
import (
"context"
"errors"
- "fmt"
+ "net/http"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
- "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
- "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/text"
- "github.com/superseriousbusiness/gotosocial/internal/util"
)
// TagTimelineGet gets a pageable timeline for the given
@@ -40,37 +38,77 @@ import (
// to requestingAcct before returning it.
func (p *Processor) TagTimelineGet(
ctx context.Context,
- requestingAcct *gtsmodel.Account,
+ requester *gtsmodel.Account,
tagName string,
maxID string,
sinceID string,
minID string,
limit int,
) (*apimodel.PageableResponse, gtserror.WithCode) {
+
+ // Fetch the requested tag with name.
tag, errWithCode := p.getTag(ctx, tagName)
if errWithCode != nil {
return nil, errWithCode
}
+ // Check for a useable returned tag for endpoint.
if tag == nil || !*tag.Useable || !*tag.Listable {
+
// Obey mastodon API by returning 404 for this.
- err := fmt.Errorf("tag was not found, or not useable/listable on this instance")
- return nil, gtserror.NewErrorNotFound(err, err.Error())
+ const text = "tag was not found, or not useable/listable on this instance"
+ return nil, gtserror.NewWithCode(http.StatusNotFound, text)
}
- statuses, err := p.state.DB.GetTagTimeline(ctx, tag.ID, maxID, sinceID, minID, limit)
- if err != nil && !errors.Is(err, db.ErrNoEntries) {
- err = gtserror.Newf("db error getting statuses: %w", err)
- return nil, gtserror.NewErrorInternalError(err)
- }
+ // Fetch status timeline for tag.
+ return p.getStatusTimeline(ctx,
+
+ // Auth'd
+ // account.
+ requester,
+
+ // No
+ // cache.
+ nil,
- return p.packageTagResponse(
- ctx,
- requestingAcct,
- statuses,
- limit,
- // Use API URL for tag.
+ // Current
+ // page.
+ &paging.Page{
+ Min: paging.EitherMinID(minID, sinceID),
+ Max: paging.MaxID(maxID),
+ Limit: limit,
+ },
+
+ // Tag timeline name's endpoint.
"/api/v1/timelines/tag/"+tagName,
+
+ // No page
+ // query.
+ nil,
+
+ // Status filter context.
+ statusfilter.FilterContextPublic,
+
+ // Database load function.
+ func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
+ return p.state.DB.GetTagTimeline(ctx, tag.ID, pg)
+ },
+
+ // Filtering function,
+ // i.e. filter before caching.
+ func(s *gtsmodel.Status) bool {
+
+ // Check the visibility of passed status to requesting user.
+ ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
+ if err != nil {
+ log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
+ }
+ return !ok
+ },
+
+ // Post filtering funtion,
+ // i.e. filter after caching.
+ nil,
)
}
@@ -92,69 +130,3 @@ func (p *Processor) getTag(ctx context.Context, tagName string) (*gtsmodel.Tag,
return tag, nil
}
-
-func (p *Processor) packageTagResponse(
- ctx context.Context,
- requestingAcct *gtsmodel.Account,
- statuses []*gtsmodel.Status,
- limit int,
- requestPath string,
-) (*apimodel.PageableResponse, gtserror.WithCode) {
- count := len(statuses)
- if count == 0 {
- return util.EmptyPageableResponse(), nil
- }
-
- var (
- items = make([]interface{}, 0, count)
-
- // Set next + prev values before filtering and API
- // converting, so caller can still page properly.
- nextMaxIDValue = statuses[count-1].ID
- prevMinIDValue = statuses[0].ID
- )
-
- filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAcct.ID)
- if err != nil {
- err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAcct.ID, err)
- return nil, gtserror.NewErrorInternalError(err)
- }
-
- mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAcct.ID, nil)
- if err != nil {
- err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAcct.ID, err)
- return nil, gtserror.NewErrorInternalError(err)
- }
- compiledMutes := usermute.NewCompiledUserMuteList(mutes)
-
- for _, s := range statuses {
- timelineable, err := p.visFilter.StatusTagTimelineable(ctx, requestingAcct, s)
- if err != nil {
- log.Errorf(ctx, "error checking status visibility: %v", err)
- continue
- }
-
- if !timelineable {
- continue
- }
-
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, statusfilter.FilterContextPublic, filters, compiledMutes)
- if errors.Is(err, statusfilter.ErrHideStatus) {
- continue
- }
- if err != nil {
- log.Errorf(ctx, "error converting to api status: %v", err)
- continue
- }
-
- items = append(items, apiStatus)
- }
-
- return util.PackagePageableResponse(util.PageableResponseParams{
- Items: items,
- Path: requestPath,
- NextMaxIDValue: nextMaxIDValue,
- PrevMinIDValue: prevMinIDValue,
- Limit: limit,
- })
-}
diff --git a/internal/processing/timeline/timeline.go b/internal/processing/timeline/timeline.go
index 5966fe864..54ea2cccd 100644
--- a/internal/processing/timeline/timeline.go
+++ b/internal/processing/timeline/timeline.go
@@ -18,9 +18,33 @@
package timeline
import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ timelinepkg "github.com/superseriousbusiness/gotosocial/internal/cache/timeline"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/util/xslices"
+)
+
+var (
+ // pre-prepared URL values to be passed in to
+ // paging response forms. The paging package always
+ // copies values before any modifications so it's
+ // safe to only use a single map variable for these.
+ localOnlyTrue = url.Values{"local": {"true"}}
+ localOnlyFalse = url.Values{"local": {"false"}}
)
type Processor struct {
@@ -36,3 +60,114 @@ func New(state *state.State, converter *typeutils.Converter, visFilter *visibili
visFilter: visFilter,
}
}
+
+func (p *Processor) getStatusTimeline(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ timeline *timelinepkg.StatusTimeline,
+ page *paging.Page,
+ pagePath string,
+ pageQuery url.Values,
+ filterCtx statusfilter.FilterContext,
+ loadPage func(*paging.Page) (statuses []*gtsmodel.Status, err error),
+ filter func(*gtsmodel.Status) (delete bool),
+ postFilter func(*gtsmodel.Status) (remove bool),
+) (
+ *apimodel.PageableResponse,
+ gtserror.WithCode,
+) {
+ var err error
+ var filters []*gtsmodel.Filter
+ var mutes *usermute.CompiledUserMuteList
+
+ if requester != nil {
+ // Fetch all filters relevant for requesting account.
+ filters, err = p.state.DB.GetFiltersForAccountID(ctx,
+ requester.ID,
+ )
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err := gtserror.Newf("error getting account filters: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Get a list of all account mutes for requester.
+ allMutes, err := p.state.DB.GetAccountMutes(ctx,
+ requester.ID,
+ nil, // i.e. all
+ )
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err := gtserror.Newf("error getting account mutes: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Compile all account mutes to useable form.
+ mutes = usermute.NewCompiledUserMuteList(allMutes)
+ }
+
+ // Ensure we have valid
+ // input paging cursor.
+ id.ValidatePage(page)
+
+ // Load status page via timeline cache, also
+ // getting lo, hi values for next, prev pages.
+ //
+ // NOTE: this safely handles the case of a nil
+ // input timeline, i.e. uncached timeline type.
+ apiStatuses, lo, hi, err := timeline.Load(ctx,
+
+ // Status page
+ // to load.
+ page,
+
+ // Caller provided database
+ // status page loading function.
+ loadPage,
+
+ // Status load function for cached timeline entries.
+ func(ids []string) ([]*gtsmodel.Status, error) {
+ return p.state.DB.GetStatusesByIDs(ctx, ids)
+ },
+
+ // Call provided status
+ // filtering function.
+ filter,
+
+ // Frontend API model preparation function.
+ func(status *gtsmodel.Status) (*apimodel.Status, error) {
+
+ // Check if status needs filtering OUTSIDE of caching stage.
+ // TODO: this will be moved to separate postFilter hook when
+ // all filtering has been removed from the type converter.
+ if postFilter != nil && postFilter(status) {
+ return nil, nil
+ }
+
+ // Finally, pass status to get converted to API model.
+ apiStatus, err := p.converter.StatusToAPIStatus(ctx,
+ status,
+ requester,
+ filterCtx,
+ filters,
+ mutes,
+ )
+ if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
+ return nil, err
+ }
+ return apiStatus, nil
+ },
+ )
+
+ if err != nil {
+ err := gtserror.Newf("error loading timeline: %w", err)
+ return nil, gtserror.WrapWithCode(http.StatusInternalServerError, err)
+ }
+
+ // Package returned API statuses as pageable response.
+ return paging.PackageResponse(paging.ResponseParams{
+ Items: xslices.ToAny(apiStatuses),
+ Path: pagePath,
+ Next: page.Next(lo, hi),
+ Prev: page.Prev(lo, hi),
+ Query: pageQuery,
+ }), nil
+}