Subject: [PATCH links] Adding pagination ordering on bookmark listing page (the getOrgLinks GraphQL call). Supports basic descending (newest first) and ascending (oldest first).

From: Peter Sanchez <[email-masked]>
Date: Wed, 30 Apr 2025 14:19:32 -0600

Implements: https://todo.code.netlandish.com/~netlandish/links/90
Signed-off-by: Peter Sanchez <[email-masked]>
Changelog-added: Pagination ordering on bookmark listing pages.
---
 api/graph/generated.go        | 25 ++++++++++++++++++++-
 api/graph/model/models_gen.go | 42 +++++++++++++++++++++++++++++++++++
 api/graph/pagination.go       | 31 ++++++++++++++++++++++----
 api/graph/schema.graphqls     |  6 +++++
 api/graph/schema.resolvers.go | 24 ++++++++++++++++++--
 cmd/links/main.go             | 10 +++++++--
 cmd/test/helpers.go           | 10 +++++++--
 core/routes.go                | 14 +++++++++++-
 helpers.go                    | 17 +++++++++-----
 templates/link_list.html      | 20 +++++++++++++----
 10 files changed, 177 insertions(+), 22 deletions(-)

diff --git a/api/graph/generated.go b/api/graph/generated.go
index c964512..ff56a5e 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -25613,7 +25613,7 @@ func (ec *executionContext) unmarshalInputGetLinkInput(ctx context.Context, obj
 		asMap[k] = v
 	}
 
-	fieldsInOrder := [...]string{"orgSlug", "limit", "after", "before", "tag", "excludeTag", "search", "filter", "tagCloudType", "tagCloudOrder"}
+	fieldsInOrder := [...]string{"orgSlug", "limit", "after", "before", "tag", "excludeTag", "search", "filter", "order", "tagCloudType", "tagCloudOrder"}
 	for _, k := range fieldsInOrder {
 		v, ok := asMap[k]
 		if !ok {
@@ -25676,6 +25676,13 @@ func (ec *executionContext) unmarshalInputGetLinkInput(ctx context.Context, obj
 				return it, err
 			}
 			it.Filter = data
+		case "order":
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("order"))
+			data, err := ec.unmarshalOOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐOrderType(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.Order = data
 		case "tagCloudType":
 			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("tagCloudType"))
 			data, err := ec.unmarshalOCloudType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudType(ctx, v)
@@ -33038,6 +33045,22 @@ func (ec *executionContext) unmarshalONoteInput2ᚖlinksᚋapiᚋgraphᚋmodel
 	return &res, graphql.ErrorOnPath(ctx, err)
 }
 
+func (ec *executionContext) unmarshalOOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐOrderType(ctx context.Context, v interface{}) (*model.OrderType, error) {
+	if v == nil {
+		return nil, nil
+	}
+	var res = new(model.OrderType)
+	err := res.UnmarshalGQL(v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalOOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐOrderType(ctx context.Context, sel ast.SelectionSet, v *model.OrderType) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	return v
+}
+
 func (ec *executionContext) marshalOOrgLink2ᚖlinksᚋmodelsᚐOrgLink(ctx context.Context, sel ast.SelectionSet, v *models.OrgLink) graphql.Marshaler {
 	if v == nil {
 		return graphql.Null
diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go
index 5d3bc9f..1d9c463 100644
--- a/api/graph/model/models_gen.go
+++ b/api/graph/model/models_gen.go
@@ -199,6 +199,7 @@ type GetLinkInput struct {
 	ExcludeTag    *string         `json:"excludeTag,omitempty"`
 	Search        *string         `json:"search,omitempty"`
 	Filter        *string         `json:"filter,omitempty"`
+	Order         *OrderType      `json:"order,omitempty"`
 	TagCloudType  *CloudType      `json:"tagCloudType,omitempty"`
 	TagCloudOrder *CloudOrderType `json:"tagCloudOrder,omitempty"`
 }
@@ -940,6 +941,47 @@ func (e MemberPermission) MarshalGQL(w io.Writer) {
 	fmt.Fprint(w, strconv.Quote(e.String()))
 }
 
+type OrderType string
+
+const (
+	OrderTypeDesc OrderType = "DESC"
+	OrderTypeAsc  OrderType = "ASC"
+)
+
+var AllOrderType = []OrderType{
+	OrderTypeDesc,
+	OrderTypeAsc,
+}
+
+func (e OrderType) IsValid() bool {
+	switch e {
+	case OrderTypeDesc, OrderTypeAsc:
+		return true
+	}
+	return false
+}
+
+func (e OrderType) String() string {
+	return string(e)
+}
+
+func (e *OrderType) UnmarshalGQL(v interface{}) error {
+	str, ok := v.(string)
+	if !ok {
+		return fmt.Errorf("enums must be strings")
+	}
+
+	*e = OrderType(str)
+	if !e.IsValid() {
+		return fmt.Errorf("%s is not a valid OrderType", str)
+	}
+	return nil
+}
+
+func (e OrderType) MarshalGQL(w io.Writer) {
+	fmt.Fprint(w, strconv.Quote(e.String()))
+}
+
 type OrgBillingStatus string
 
 const (
diff --git a/api/graph/pagination.go b/api/graph/pagination.go
index 82043a4..6312c5a 100644
--- a/api/graph/pagination.go
+++ b/api/graph/pagination.go
@@ -37,10 +37,28 @@ func PaginateResults[T any](items []T, limit int, before, after *model.Cursor,
 	return items, &pageInfo
 }
 
+func cursorField(before, after *model.Cursor, idField, orderDir string) sq.Sqlizer {
+	if after != nil {
+		if orderDir == "DESC" {
+			return sq.Lt{idField: after.After}
+		} else if orderDir == "ASC" {
+			return sq.Gt{idField: after.After}
+		}
+	} else if before != nil {
+		if orderDir == "DESC" {
+			return sq.Gt{idField: before.Before}
+		} else if orderDir == "ASC" {
+			return sq.Lt{idField: before.Before}
+		}
+	}
+	// Should never be reached.
+	return nil
+}
+
 func QueryModel[T any](
 	ctx context.Context,
 	opts *database.FilterOptions,
-	idField string,
+	idField, orderDir string,
 	limit *int,
 	before, after *model.Cursor,
 	getModels func(context.Context, *database.FilterOptions) ([]T, error),
@@ -50,15 +68,20 @@ func QueryModel[T any](
 	if after != nil {
 		opts.Filter = sq.And{
 			opts.Filter,
-			sq.Lt{idField: after.After},
+			cursorField(before, after, idField, orderDir),
 		}
 		numElements = after.Limit
 	} else if before != nil {
 		opts.Filter = sq.And{
 			opts.Filter,
-			sq.Gt{idField: before.Before},
+			cursorField(before, after, idField, orderDir),
+		}
+		if orderDir == "DESC" {
+			opts.OrderBy = idField + " ASC"
+		} else {
+			opts.OrderBy = idField + " DESC"
+
 		}
-		opts.OrderBy = idField + " ASC"
 		numElements = before.Limit
 	}
 
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index e170c97..adfa596 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -128,6 +128,11 @@ enum CloudOrderType {
   NAME_DESC
 }
 
+enum OrderType {
+  DESC
+  ASC
+}
+
 
 # Considering removing these Null* fields:
 # https://todo.code.netlandish.com/~netlandish/links/75
@@ -601,6 +606,7 @@ input GetLinkInput {
     excludeTag: String
     search: String
     filter: String
+    order: OrderType
     tagCloudType: CloudType
     tagCloudOrder: CloudOrderType
 }
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index ec38373..18a5734 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -4777,7 +4777,7 @@ func (r *qRCodeResolver) CodeType(ctx context.Context, obj *models.QRCode) (mode
 func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
 	return &model.Version{
 		Major:           0,
-		Minor:           3,
+		Minor:           4,
 		Patch:           1,
 		DeprecationDate: nil,
 	}, nil
@@ -4984,6 +4984,7 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP
 		ctx,
 		opts,
 		"i.id",
+		"DESC",
 		input.Limit,
 		input.Before,
 		input.After,
@@ -5236,6 +5237,7 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp
 		return nil, nil
 	}
 
+	order := model.OrderTypeDesc
 	cloudType := model.CloudTypeLinks
 	cloudOrder := model.CloudOrderTypeNameAsc
 	if input.TagCloudType != nil && *input.TagCloudType != "" {
@@ -5245,9 +5247,18 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp
 		cloudOrder = *input.TagCloudOrder
 	}
 
+	if input.Order != nil {
+		order = *input.Order
+	}
+
+	orderDir := "DESC"
+	if order == model.OrderTypeAsc {
+		orderDir = "ASC"
+	}
+
 	linkOpts := &database.FilterOptions{
 		Filter:  sq.And{},
-		OrderBy: "ol.id DESC",
+		OrderBy: "ol.id " + orderDir,
 	}
 
 	var org *models.Organization
@@ -5348,6 +5359,7 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp
 		ctx,
 		linkOpts,
 		"ol.id",
+		orderDir,
 		input.Limit,
 		input.Before,
 		input.After,
@@ -5621,6 +5633,7 @@ func (r *queryResolver) GetLinkShorts(ctx context.Context, input *model.GetLinkS
 		ctx,
 		linkOpts,
 		"l.id",
+		"DESC",
 		input.Limit,
 		input.Before,
 		input.After,
@@ -5744,6 +5757,7 @@ func (r *queryResolver) GetListings(ctx context.Context, input *model.GetListing
 		ctx,
 		listingOpts,
 		"l.id",
+		"DESC",
 		input.Limit,
 		input.Before,
 		input.After,
@@ -5828,6 +5842,7 @@ func (r *queryResolver) GetListing(ctx context.Context, input *model.GetListingD
 		ctx,
 		linkOpts,
 		"ll.link_order",
+		"DESC",
 		input.Limit,
 		input.Before,
 		input.After,
@@ -6412,6 +6427,7 @@ func (r *queryResolver) GetFeed(ctx context.Context, input *model.GetFeedInput)
 		ctx,
 		linkOpts,
 		"ol.id",
+		"DESC",
 		input.Limit,
 		input.Before,
 		input.After,
@@ -6596,6 +6612,7 @@ func (r *queryResolver) GetAuditLogs(ctx context.Context, input *model.AuditLogI
 		ctx,
 		opts,
 		"al.id",
+		"DESC",
 		input.Limit,
 		input.Before,
 		input.After,
@@ -6659,6 +6676,7 @@ func (r *queryResolver) GetUsers(ctx context.Context, input *model.GetUserInput)
 		ctx,
 		opts,
 		"u.id",
+		"DESC",
 		input.Limit,
 		input.Before,
 		input.After,
@@ -6750,6 +6768,7 @@ func (r *queryResolver) GetAdminOrganizations(ctx context.Context, input *model.
 		ctx,
 		opts,
 		"o.id",
+		"DESC",
 		input.Limit,
 		input.Before,
 		input.After,
@@ -7000,6 +7019,7 @@ func (r *queryResolver) GetAdminDomains(ctx context.Context, input *model.GetAdm
 		ctx,
 		opts,
 		"d.id",
+		"DESC",
 		input.Limit,
 		input.Before,
 		input.After,
diff --git a/cmd/links/main.go b/cmd/links/main.go
index b8d7ade..2916ce0 100644
--- a/cmd/links/main.go
+++ b/cmd/links/main.go
@@ -3,6 +3,7 @@ package main
 import (
 	"context"
 	"fmt"
+	htemplate "html/template"
 	"net/http"
 	"net/url"
 	"os"
@@ -293,8 +294,13 @@ func run() error {
 			}
 			return slices.Contains(tags, strings.TrimSpace(tag))
 		},
-		"addQueryElement": links.AddQueryElement,
-		"getAddLinkURL":   links.GetAddLinkURL,
+		"addQueryElement": func(q htemplate.URL, param, val string) htemplate.URL {
+			return links.AddQueryElement(q, param, val, false)
+		},
+		"setQueryElement": func(q htemplate.URL, param, val string) htemplate.URL {
+			return links.AddQueryElement(q, param, val, true)
+		},
+		"getAddLinkURL": links.GetAddLinkURL,
 		"newlinebr": func(blob string) string {
 			return strings.ReplaceAll(blob, "\n", "<br />\n")
 		},
diff --git a/cmd/test/helpers.go b/cmd/test/helpers.go
index 1396162..d6d350e 100644
--- a/cmd/test/helpers.go
+++ b/cmd/test/helpers.go
@@ -6,6 +6,7 @@ import (
 	"database/sql"
 	"encoding/hex"
 	"fmt"
+	htemplate "html/template"
 	"io"
 	"links"
 	"links/accounts"
@@ -130,8 +131,13 @@ func NewWebTestServer(t *testing.T) (*server.Server, *echo.Echo) {
 		"isTagUsedInFilter": func(tag string, activeTags string) bool {
 			return strings.Contains(activeTags, tag)
 		},
-		"addQueryElement": links.AddQueryElement,
-		"getAddLinkURL":   links.GetAddLinkURL,
+		"addQueryElement": func(q htemplate.URL, param, val string) htemplate.URL {
+			return links.AddQueryElement(q, param, val, false)
+		},
+		"setQueryElement": func(q htemplate.URL, param, val string) htemplate.URL {
+			return links.AddQueryElement(q, param, val, true)
+		},
+		"getAddLinkURL": links.GetAddLinkURL,
 		"newlinebr": func(blob string) string {
 			return strings.ReplaceAll(blob, "\n", "<br />\n")
 		},
diff --git a/core/routes.go b/core/routes.go
index e97ef17..76ad4f8 100644
--- a/core/routes.go
+++ b/core/routes.go
@@ -2065,7 +2065,8 @@ func (s *Service) OrgLinksList(c echo.Context) error {
 	op := gqlclient.NewOperation(
 		`query GetOrgLinks($slug: String, $after: Cursor, $before: Cursor,
 						   $tag: String, $excludeTag: String, $search: String, 
-						   $filter: String, $cloudType: CloudType, $cloudOrder: CloudOrderType) {
+						   $filter: String, $order: OrderType, $cloudType: CloudType, 
+						   $cloudOrder: CloudOrderType) {
 				getOrgLinks(input: {
 						orgSlug: $slug,
 						after: $after,
@@ -2074,6 +2075,7 @@ func (s *Service) OrgLinksList(c echo.Context) error {
 						excludeTag: $excludeTag,
 						search: $search,
 						filter: $filter,
+						order: $order,
 						tagCloudType: $cloudType,
 						tagCloudOrder: $cloudOrder
 					}) {
@@ -2274,6 +2276,13 @@ func (s *Service) OrgLinksList(c echo.Context) error {
 		queries.Add("q", search)
 	}
 
+	orderDir := c.QueryParam("order")
+	if orderDir != "" && (orderDir == "DESC" || orderDir == "ASC") {
+		op.Var("order", orderDir)
+	} else {
+		orderDir = "DESC" // default
+	}
+
 	err = links.Execute(links.LangContext(c), op, &result)
 	if err != nil {
 		if graphError, ok := err.(*gqlclient.Error); ok {
@@ -2319,6 +2328,8 @@ func (s *Service) OrgLinksList(c echo.Context) error {
 	pd.Data["follow"] = lt.Translate("Follow")
 	pd.Data["unfollow"] = lt.Translate("Unfollow")
 	pd.Data["tags"] = lt.Translate("Tags")
+	pd.Data["newest"] = lt.Translate("newest")
+	pd.Data["oldest"] = lt.Translate("oldest")
 	orgLinks := result.OrgLinks.Result
 	if links.IsRSS(c.Path()) {
 		domain := fmt.Sprintf("%s://%s", gctx.Server.Config.Scheme, gctx.Server.Config.Domain)
@@ -2409,6 +2420,7 @@ func (s *Service) OrgLinksList(c echo.Context) error {
 		"rssURL":            rssURL,
 		"followAction":      followAction,
 		"tagCloud":          result.OrgLinks.TagCloud,
+		"orderDir":          orderDir,
 	}
 
 	if search != "" {
diff --git a/helpers.go b/helpers.go
index a2fbc3d..9ce16dc 100644
--- a/helpers.go
+++ b/helpers.go
@@ -879,18 +879,23 @@ func ParseSearch(s string) string {
 	return s
 }
 
-func AddQueryElement(q template.URL, param, val string) template.URL {
+func AddQueryElement(q template.URL, param, val string, replace bool) template.URL {
 	query, err := url.ParseQuery(string(q))
 	if err != nil {
 		return ""
 	}
-	curVal := query.Get(param)
-	if curVal == "" {
-		curVal = val
+
+	if !replace {
+		curVal := query.Get(param)
+		if curVal == "" {
+			curVal = val
+		} else {
+			curVal += fmt.Sprintf(",%s", val)
+		}
+		query.Set(param, curVal)
 	} else {
-		curVal += fmt.Sprintf(",%s", val)
+		query.Set(param, val)
 	}
-	query.Set(param, curVal)
 	return template.URL(query.Encode())
 }
 
diff --git a/templates/link_list.html b/templates/link_list.html
index a019bbd..e9c053c 100644
--- a/templates/link_list.html
+++ b/templates/link_list.html
@@ -226,9 +226,21 @@
   </footer>
   {{end}}
       </div> {{- /* col-9 */ -}}
-      <div class="col-3">
+      <div class="col-3"> {{- /* right side bar */ -}}
+          <div class="row">
+	    <div class="col-2">
+	      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="menu-item__icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" /></svg>
+	    </div>
+	    <div class="col is-vertical-align">
+	    {{ if eq .orderDir "DESC" }}
+	      <small class="link-tag__side link-tag__item--simple">{{ .pd.Data.newest }}</small>&nbsp; &bull;&nbsp; <a href="{{if $.queries}}?{{setQueryElement $.queries "order" "ASC"}}{{else}}?order=ASC{{end}}" class="link-tag__side link-tag__item--simple">{{ .pd.Data.oldest }}</a>
+	    {{ else }}
+	      <a href="{{if $.queries}}?{{setQueryElement $.queries "order" "DESC"}}{{else}}?order=DESC{{end}}" class="link-tag__side link-tag__item--simple">{{ .pd.Data.newest }}</a>&nbsp; &bull;&nbsp; <small class="link-tag__side link-tag__item--simple">{{ .pd.Data.oldest }}</small>
+	    {{ end }}
+	    </div>
+	  </div>
           <p>{{ .pd.Data.tags }}</p>
-	  <hr></hr>
+	  <hr>
 	  <p>
 	  {{ range .tagCloud }}
 	    {{if isTagUsedInFilter .Slug $.tagFilter}}
@@ -238,7 +250,7 @@
             {{end}}
 	  {{ end }}
 	  </p>
-      </div>
-  </div> {{- /* row */ -}}
+      </div> {{- /* end right side bar */ -}}
+  </div> {{- /* end row */ -}}
 </section>
 {{template "base_footer" .}}
-- 
2.47.2