pageable.gno
8.19 Kb · 304 lines
1// Package pageable implements a flexible pagination system that can be used with any data structure
2// that implements the Pageable interface. It provides functionality for:
3//
4// - Paginating through collections of items
5// - Configurable page sizes
6// - Forward and reverse iteration
7// - URL query parameter parsing
8// - Markdown-based pagination UI
9//
10// Nothing imports it yet. An earlier version of this comment named
11// gno.land/p/nt/avl/pager and gno.land/p/moul/ulist/pager as consumers; neither
12// is one. The upstream avl pager is a standalone implementation that does not
13// go through this interface, and ulist ships lplist, not a pager. Treat this as
14// a proposed interface with a reference Pager and a test, not as load-bearing
15// code.
16package pageable
17
18import (
19 "math"
20 "net/url"
21 "strconv"
22
23 "gno.land/p/nt/ufmt/v0"
24)
25
26// Pageable defines the minimal interface required for pagination.
27// This interface is intentionally lightweight, requiring only two methods.
28// While some data structures (like AVL trees) might have separate methods for forward
29// and reverse iteration, this interface consolidates both directions into a single
30// IterateByOffset method. It's the responsibility of the implementing wrapper to
31// handle the direction logic internally based on the offset and count parameters.
32type Pageable interface {
33 // Size returns the total number of items
34 Size() int
35
36 // IterateByOffset performs iteration starting from offset for count elements.
37 // The callback receives an index and a value, returns true to stop iteration.
38 //
39 // For reverse iteration, the implementation should handle the direction internally
40 // by adjusting how it interprets the offset and count parameters. For example:
41 // - In forward mode: offset 0, count 5 would return items [0,1,2,3,4]
42 // - In reverse mode: offset 0, count -5 would return items [4,3,2,1,0]
43 //
44 // This approach allows the interface to remain simple while supporting
45 // bidirectional iteration through the implementation layer.
46 IterateByOffset(offset int, count int, cb func(index interface{}, value interface{}) bool) bool
47}
48
49// Pager provides pagination functionality for any Pageable source
50type Pager struct {
51 Source Pageable
52 PageQueryParam string
53 SizeQueryParam string
54 DefaultPageSize int
55 Reversed bool
56}
57
58// Page represents a single page of results
59type Page struct {
60 Items []Item
61 PageNumber int
62 PageSize int
63 TotalItems int
64 TotalPages int
65 HasPrev bool
66 HasNext bool
67 Pager *Pager
68}
69
70// Item represents a generic item in the page
71type Item struct {
72 Index interface{}
73 Value interface{}
74}
75
76// NewPager creates a new Pager with default values
77func NewPager(source Pageable, defaultPageSize int, reversed bool) *Pager {
78 return &Pager{
79 Source: source,
80 PageQueryParam: "page",
81 SizeQueryParam: "size",
82 DefaultPageSize: defaultPageSize,
83 Reversed: reversed,
84 }
85}
86
87// GetPage retrieves a page of results from the AVL tree.
88func (p *Pager) GetPage(pageNumber int) *Page {
89 return p.GetPageWithSize(pageNumber, p.DefaultPageSize)
90}
91
92// Helper function to create a basic page
93func (p *Pager) newBasePage(pageSize int) *Page {
94 totalItems := p.Source.Size()
95 totalPages := 0
96 if pageSize > 0 {
97 totalPages = int(math.Ceil(float64(totalItems) / float64(pageSize)))
98 }
99
100 return &Page{
101 TotalItems: totalItems,
102 TotalPages: totalPages,
103 PageSize: pageSize,
104 Pager: p,
105 }
106}
107
108func (p *Pager) GetPageWithSize(pageNumber, pageSize int) *Page {
109 // Handle invalid page size
110 if pageSize <= 0 {
111 return p.newBasePage(pageSize)
112 }
113
114 page := p.newBasePage(pageSize)
115
116 // For empty source, return empty page with no navigation
117 if page.TotalItems == 0 {
118 page.HasPrev = false
119 page.HasNext = false
120 return page
121 }
122
123 // page number provided is not available
124 if pageNumber < 1 {
125 page.HasNext = page.TotalPages > 0
126 return page
127 }
128
129 // page number provided is outside the range of total pages
130 if pageNumber > page.TotalPages {
131 page.PageNumber = pageNumber
132 page.HasPrev = page.TotalPages > 0
133 return page
134 }
135
136 // Calculate offset and size
137 var offset, iterCount int
138 if p.Reversed {
139 // For reversed order, calculate from the end
140 iterCount = min(pageSize, page.TotalItems-((pageNumber-1)*pageSize))
141 offset = max(0, page.TotalItems-(pageNumber*pageSize))
142 } else {
143 offset = (pageNumber - 1) * pageSize
144 iterCount = pageSize
145 }
146
147 // Collect items for the current page
148 var items []Item
149 if p.Reversed {
150 // For reversed order, use negative count to indicate reverse iteration
151 offset = page.TotalItems - offset - iterCount
152 p.Source.IterateByOffset(offset, -iterCount, func(index interface{}, value interface{}) bool {
153 items = append(items, Item{Index: index, Value: value})
154 return false
155 })
156 } else {
157 p.Source.IterateByOffset(offset, iterCount, func(index interface{}, value interface{}) bool {
158 items = append(items, Item{Index: index, Value: value})
159 return false
160 })
161 }
162
163 page.Items = items
164 page.PageNumber = pageNumber
165 page.HasPrev = pageNumber > 1
166 page.HasNext = pageNumber < page.TotalPages
167 return page
168}
169
170func (p *Pager) MustGetPageByPath(rawURL string) *Page {
171 page, err := p.GetPageByPath(rawURL)
172 if err != nil {
173 panic("invalid path")
174 }
175 return page
176}
177
178// GetPageByPath retrieves a page of results based on the query parameters in the URL path.
179func (p *Pager) GetPageByPath(rawURL string) (*Page, error) {
180 pageNumber, pageSize, err := p.ParseQuery(rawURL)
181 if err != nil {
182 return nil, err
183 }
184 return p.GetPageWithSize(pageNumber, pageSize), nil
185}
186
187// Picker generates the Markdown UI for the page picker using default query string format
188func (p *Page) Picker() string {
189 return p.PickerWithPath("")
190}
191
192// PickerWithPath generates the Markdown UI for the page picker with optional path
193// If path is provided, it preserves existing query parameters (except page)
194func (p *Page) PickerWithPath(path string) string {
195 pageNumber := p.PageNumber
196 pageNumber = max(pageNumber, 1)
197
198 if p.TotalPages <= 1 {
199 return ""
200 }
201
202 // Parse existing query parameters if path is provided
203 baseQuery := ""
204 if path != "" {
205 u, _ := url.Parse(path)
206 if u != nil {
207 query := u.Query()
208 // Remove existing page query parameter
209 query.Del(p.Pager.PageQueryParam)
210 // Encode remaining query parameters
211 baseQuery = query.Encode()
212 if baseQuery != "" {
213 baseQuery = "&" + baseQuery
214 }
215 }
216 }
217
218 md := ""
219
220 if p.HasPrev {
221 // Always show the first page link
222 md += ufmt.Sprintf("[%d](?%s=%d%s) | ", 1, p.Pager.PageQueryParam, 1, baseQuery)
223
224 // Before
225 if p.PageNumber > 4 {
226 md += "… | "
227 }
228
229 if p.PageNumber > 3 {
230 md += ufmt.Sprintf("[%d](?%s=%d%s) | ", p.PageNumber-2, p.Pager.PageQueryParam, p.PageNumber-2, baseQuery)
231 }
232
233 if p.PageNumber > 2 {
234 md += ufmt.Sprintf("[%d](?%s=%d%s) | ", p.PageNumber-1, p.Pager.PageQueryParam, p.PageNumber-1, baseQuery)
235 }
236 }
237
238 if p.PageNumber > 0 && p.PageNumber <= p.TotalPages {
239 // Current page
240 md += ufmt.Sprintf("**%d**", p.PageNumber)
241 } else {
242 md += ufmt.Sprintf("_%d_", p.PageNumber)
243 }
244
245 if p.HasNext {
246 md += " | "
247
248 if p.PageNumber < p.TotalPages-1 {
249 md += ufmt.Sprintf("[%d](?%s=%d%s) | ", p.PageNumber+1, p.Pager.PageQueryParam, p.PageNumber+1, baseQuery)
250 }
251
252 if p.PageNumber < p.TotalPages-2 {
253 md += ufmt.Sprintf("[%d](?%s=%d%s) | ", p.PageNumber+2, p.Pager.PageQueryParam, p.PageNumber+2, baseQuery)
254 }
255
256 if p.PageNumber < p.TotalPages-3 {
257 md += "… | "
258 }
259
260 // Always show the last page link
261 md += ufmt.Sprintf("[%d](?%s=%d%s)", p.TotalPages, p.Pager.PageQueryParam, p.TotalPages, baseQuery)
262 }
263
264 return md
265}
266
267// Helper to safely parse positive integers with a default value
268func parsePositiveIntOrDefault(str string, defaultValue int) int {
269 if str == "" {
270 return defaultValue
271 }
272 val, err := strconv.Atoi(str)
273 if err != nil || val < 1 {
274 return defaultValue
275 }
276 return val
277}
278
279func (p *Pager) ParseQuery(rawURL string) (int, int, error) {
280 u, err := url.Parse(rawURL)
281 if err != nil {
282 return 1, p.DefaultPageSize, err
283 }
284
285 query := u.Query()
286 pageNumber := parsePositiveIntOrDefault(query.Get(p.PageQueryParam), 1)
287 pageSize := parsePositiveIntOrDefault(query.Get(p.SizeQueryParam), p.DefaultPageSize)
288
289 return pageNumber, pageSize, nil
290}
291
292func max(a, b int) int {
293 if a > b {
294 return a
295 }
296 return b
297}
298
299func min(a, b int) int {
300 if a < b {
301 return a
302 }
303 return b
304}