pagy 43.0.0 → 43.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/apps/calendar.ru +11 -12
  4. data/apps/demo.ru +5 -5
  5. data/apps/enable_rails_page_segment.rb +54 -0
  6. data/apps/index.rb +1 -1
  7. data/apps/keynav+root_key.ru +316 -0
  8. data/apps/keynav.ru +10 -13
  9. data/apps/keyset.ru +5 -11
  10. data/apps/keyset_sequel.ru +10 -12
  11. data/apps/rails.ru +8 -12
  12. data/apps/repro.ru +11 -11
  13. data/bin/pagy +2 -94
  14. data/config/pagy.rb +8 -7
  15. data/javascripts/ai_widget.js +65 -51
  16. data/javascripts/pagy.js +20 -17
  17. data/javascripts/pagy.js.map +3 -3
  18. data/javascripts/pagy.min.js +2 -1
  19. data/javascripts/pagy.mjs +19 -16
  20. data/javascripts/wand.js +15 -9
  21. data/lib/pagy/classes/calendar/calendar.rb +36 -31
  22. data/lib/pagy/classes/calendar/day.rb +1 -1
  23. data/lib/pagy/classes/calendar/month.rb +1 -1
  24. data/lib/pagy/classes/calendar/quarter.rb +1 -1
  25. data/lib/pagy/classes/calendar/unit.rb +12 -13
  26. data/lib/pagy/classes/calendar/year.rb +1 -1
  27. data/lib/pagy/classes/exceptions.rb +1 -8
  28. data/lib/pagy/classes/keyset/adapters/active_record.rb +3 -1
  29. data/lib/pagy/classes/keyset/adapters/sequel.rb +3 -1
  30. data/lib/pagy/classes/keyset/keynav.rb +9 -4
  31. data/lib/pagy/classes/keyset/keyset.rb +57 -32
  32. data/lib/pagy/classes/offset/countish.rb +17 -0
  33. data/lib/pagy/classes/offset/countless.rb +26 -14
  34. data/lib/pagy/classes/offset/offset.rb +8 -2
  35. data/lib/pagy/classes/offset/search.rb +6 -10
  36. data/lib/pagy/classes/request.rb +29 -20
  37. data/lib/pagy/cli.rb +135 -0
  38. data/lib/pagy/console.rb +6 -0
  39. data/lib/pagy/modules/abilities/configurable.rb +2 -2
  40. data/lib/pagy/modules/abilities/countable.rb +24 -0
  41. data/lib/pagy/modules/abilities/linkable.rb +35 -24
  42. data/lib/pagy/modules/abilities/rangeable.rb +3 -3
  43. data/lib/pagy/modules/b64.rb +9 -3
  44. data/lib/pagy/modules/console.rb +15 -20
  45. data/lib/pagy/modules/i18n/i18n.rb +38 -14
  46. data/lib/pagy/modules/i18n/p11n/arabic.rb +1 -0
  47. data/lib/pagy/modules/i18n/p11n/east_slavic.rb +1 -0
  48. data/lib/pagy/modules/i18n/p11n/polish.rb +1 -0
  49. data/lib/pagy/modules/searcher.rb +9 -8
  50. data/lib/pagy/toolbox/helpers/anchor_tags.rb +11 -15
  51. data/lib/pagy/toolbox/helpers/bootstrap/input_nav_js.rb +3 -0
  52. data/lib/pagy/toolbox/helpers/bootstrap/series_nav.rb +3 -1
  53. data/lib/pagy/toolbox/helpers/bootstrap/series_nav_js.rb +2 -0
  54. data/lib/pagy/toolbox/helpers/bulma/input_nav_js.rb +3 -0
  55. data/lib/pagy/toolbox/helpers/bulma/previous_next_html.rb +1 -1
  56. data/lib/pagy/toolbox/helpers/bulma/series_nav.rb +3 -1
  57. data/lib/pagy/toolbox/helpers/bulma/series_nav_js.rb +2 -0
  58. data/lib/pagy/toolbox/helpers/data_hash.rb +19 -17
  59. data/lib/pagy/toolbox/helpers/headers_hash.rb +15 -9
  60. data/lib/pagy/toolbox/helpers/info_tag.rb +2 -0
  61. data/lib/pagy/toolbox/helpers/input_nav_js.rb +9 -6
  62. data/lib/pagy/toolbox/helpers/limit_tag_js.rb +4 -3
  63. data/lib/pagy/toolbox/helpers/loader.rb +3 -0
  64. data/lib/pagy/toolbox/helpers/page_url.rb +10 -16
  65. data/lib/pagy/toolbox/helpers/series_nav.rb +5 -4
  66. data/lib/pagy/toolbox/helpers/series_nav_js.rb +2 -1
  67. data/lib/pagy/toolbox/helpers/support/a_lambda.rb +8 -6
  68. data/lib/pagy/toolbox/helpers/support/data_pagy_attribute.rb +6 -1
  69. data/lib/pagy/toolbox/helpers/support/series.rb +1 -2
  70. data/lib/pagy/toolbox/helpers/support/wrap_input_nav_js.rb +1 -1
  71. data/lib/pagy/toolbox/helpers/support/wrap_series_nav.rb +2 -1
  72. data/lib/pagy/toolbox/helpers/support/wrap_series_nav_js.rb +10 -4
  73. data/lib/pagy/toolbox/helpers/urls_hash.rb +7 -7
  74. data/lib/pagy/toolbox/paginators/calendar.rb +13 -9
  75. data/lib/pagy/toolbox/paginators/countish.rb +39 -0
  76. data/lib/pagy/toolbox/paginators/countless.rb +13 -15
  77. data/lib/pagy/toolbox/paginators/elasticsearch_rails.rb +43 -18
  78. data/lib/pagy/toolbox/paginators/keynav_js.rb +14 -15
  79. data/lib/pagy/toolbox/paginators/keyset.rb +7 -9
  80. data/lib/pagy/toolbox/paginators/meilisearch.rb +21 -18
  81. data/lib/pagy/toolbox/paginators/method.rb +15 -3
  82. data/lib/pagy/toolbox/paginators/offset.rb +14 -22
  83. data/lib/pagy/toolbox/paginators/searchkick.rb +21 -18
  84. data/lib/pagy/toolbox/paginators/typesense_rails.rb +35 -0
  85. data/lib/pagy.rb +23 -10
  86. data/locales/id.yml +1 -3
  87. data/locales/ja.yml +1 -3
  88. data/locales/km.yml +1 -3
  89. data/locales/sw.yml +2 -2
  90. data/locales/tr.yml +10 -8
  91. data/stylesheets/pagy-tailwind.css +1 -1
  92. data/stylesheets/pagy.css +1 -6
  93. metadata +25 -8
  94. data/lib/optimist.rb +0 -1022
  95. data/lib/pagy/classes/keyset/active_record.rb +0 -11
  96. data/lib/pagy/classes/keyset/keynav/active_record.rb +0 -13
  97. data/lib/pagy/classes/keyset/keynav/sequel.rb +0 -13
  98. data/lib/pagy/classes/keyset/sequel.rb +0 -11
@@ -6,37 +6,57 @@ require_relative '../../modules/b64'
6
6
  class Pagy
7
7
  # Implement wicked-fast keyset pagination for big data
8
8
  class Keyset < Pagy
9
- path = Pathname.new(__dir__)
10
- autoload :ActiveRecord, path.join('active_record')
11
- autoload :Sequel, path.join('sequel')
12
- autoload :Keynav, path.join('keynav')
9
+ # Autoload adapters: files are loaded only when const_get accesses them
10
+ module Adapters
11
+ path = Pathname.new(__dir__)
12
+ autoload :ActiveRecord, path.join('adapters/active_record')
13
+ autoload :Sequel, path.join('adapters/sequel')
14
+ end
15
+
16
+ autoload :Keynav, Pathname.new(__dir__).join('keynav')
17
+
18
+ # Define empty subclasses to allow specific typing without triggering autoload
19
+ class ActiveRecord < self; end
20
+ class Sequel < self; end
13
21
 
14
22
  class TypeError < ::TypeError; end
15
23
 
16
- # Initialize Keyset* and Keyset::Keynav* classes and subclasses
24
+ # Factory method: detects the set type, configures the subclass, and instantiates
17
25
  def self.new(set, **)
18
- # Subclass instances run only the initializer
19
- if /::(?:ActiveRecord|Sequel)$/.match?(name) # check without triggering autoload
20
- return allocate.tap { |instance| instance.send(:initialize, set, **) }
26
+ # 1. Handle direct subclass usage (e.g. Pagy::Keyset::ActiveRecord.new)
27
+ if /::(?:ActiveRecord|Sequel)$/.match?(name)
28
+ # Ensure the adapter is mixed in (lazy load)
29
+ mix_in_adapter(name.split('::').last)
30
+ return allocate.tap { _1.send(:initialize, set, **) }
21
31
  end
22
32
 
23
- subclass = if defined?(::ActiveRecord) && set.is_a?(::ActiveRecord::Relation)
24
- self::ActiveRecord
25
- elsif defined?(::Sequel) && set.is_a?(::Sequel::Dataset)
26
- self::Sequel
27
- else
28
- raise TypeError, "expected an ActiveRecord::Relation or Sequel::Dataset; got #{set.class}"
29
- end
30
- subclass.new(set, **)
33
+ # 2. Handle Factory usage (Pagy::Keyset.new)
34
+ adapter = if defined?(::ActiveRecord) && set.is_a?(::ActiveRecord::Relation)
35
+ :ActiveRecord
36
+ elsif defined?(::Sequel) && set.is_a?(::Sequel::Dataset)
37
+ :Sequel
38
+ else
39
+ raise TypeError, "expected an ActiveRecord::Relation or Sequel::Dataset; got #{set.class}"
40
+ end
41
+
42
+ const_get(adapter).tap { _1.mix_in_adapter(adapter) }.new(set, **)
43
+ end
44
+
45
+ # Helper to lazy-include the adapter module
46
+ def self.mix_in_adapter(adapter)
47
+ adapter_module = Adapters.const_get(adapter)
48
+ include(adapter_module) unless self < adapter_module
31
49
  end
32
50
 
33
- def initialize(set, **) # rubocop:disable Lint/MissingSuper
51
+ def initialize(set, **)
34
52
  assign_options(**)
35
53
  assign_and_check(limit: 1)
36
54
  @set = set
37
55
  @keyset = @options[:keyset] || extract_keyset
38
56
  raise InternalError, 'the set must be ordered' if @keyset.empty?
39
57
 
58
+ @identifiers = quoted_identifiers(@set.model.table_name)
59
+
40
60
  assign_page
41
61
  self.next
42
62
  end
@@ -91,23 +111,28 @@ class Pagy
91
111
  def compose_predicate(prefix = nil)
92
112
  operator = { asc: '>', desc: '<' }
93
113
  directions = @keyset.values
94
- identifier = quoted_identifiers(@set.model.table_name)
114
+ identifier = @identifiers
95
115
  placeholder = @keyset.to_h { |column| [column, ":#{prefix}#{column}"] }
96
- if @options[:tuple_comparison] && (directions.all?(:asc) || directions.all?(:desc))
97
- "(#{identifier.values.join(', ')}) #{operator[directions.first]} (#{placeholder.values.join(', ')})"
98
- else
99
- keyset = @keyset.to_a
100
- union = []
101
- until keyset.empty?
102
- last_column, last_direction = keyset.pop
103
- intersection = +'('
104
- intersection << (keyset.map { |column, _d| "#{identifier[column]} = #{placeholder[column]}" } \
105
- << "#{identifier[last_column]} #{operator[last_direction]} #{placeholder[last_column]}").join(' AND ')
106
- intersection << ')'
107
- union << intersection
108
- end
109
- union.join(' OR ')
116
+
117
+ return "(#{identifier.values.join(', ')}) #{operator[directions.first]} (#{placeholder.values.join(', ')})" \
118
+ if @options[:tuple_comparison] && (directions.all?(:asc) || directions.all?(:desc))
119
+
120
+ keyset = @keyset.to_a
121
+ ors = []
122
+ until keyset.empty?
123
+ column, direction = keyset.pop
124
+ ands = keyset.map { |k, _| "#{identifier[k]} = #{placeholder[k]}" }
125
+ ands << "#{identifier[column]} #{operator[direction]} #{placeholder[column]}"
126
+ ors << "(#{ands.join(' AND ')})"
110
127
  end
128
+ query = ors.join(' OR ')
129
+
130
+ return query unless @keyset.size > 1
131
+
132
+ # Add hint predicate for DB optimizers that struggle with ORs
133
+ column, direction = @keyset.first
134
+ hint = "#{identifier[column]} #{operator[direction]}= #{placeholder[column]}"
135
+ "#{hint} AND (#{query})"
111
136
  end
112
137
 
113
138
  # Return the prefixed arguments from a cutoff
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Pagy
4
+ class Offset
5
+ # Offset pagination with memoized count
6
+ class Countish < Offset
7
+ protected
8
+
9
+ # Return page+count or page+count+epoch
10
+ def compose_page_param(page)
11
+ value = "#{page || 1}+#{@count}"
12
+ value << "+#{@options[:epoch]}" if @options[:epoch]
13
+ EscapedValue.new(value)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -4,11 +4,11 @@ class Pagy
4
4
  class Offset
5
5
  # Offset pagination without a count
6
6
  class Countless < Offset
7
- def initialize(**) # rubocop:disable Lint/MissingSuper
7
+ def initialize(**)
8
8
  assign_options(**)
9
9
  assign_and_check(limit: 1, page: 1)
10
10
  @page = upto_max_pages(@page)
11
- assign_last
11
+ @last = upto_max_pages(@options[:last]) unless @options[:headless]
12
12
  assign_offset
13
13
  end
14
14
 
@@ -25,32 +25,44 @@ class Pagy
25
25
  def countless? = true
26
26
 
27
27
  def upto_max_pages(value)
28
- return value unless @options[:max_pages]
28
+ return value unless value && @options[:max_pages]
29
29
 
30
30
  [value, @options[:max_pages]].min
31
31
  end
32
32
 
33
- def assign_last
34
- return unless @options[:last]
35
-
36
- @last = upto_max_pages(@options[:last].to_i)
37
- end
38
-
39
33
  # Finalize the instance variables based on the fetched size
40
34
  def finalize(fetched_size)
41
- return self unless in_range? { fetched_size.positive? || @page == 1 }
35
+ # empty records (trigger the right info message for known 0 count)
36
+ @count = 0 if fetched_size.zero? && @page == 1
42
37
 
43
- if @last && @page < @last # visited page
44
- @last = @page unless fetched_size > @limit # set last if last page
45
- else
46
- @last = upto_max_pages(fetched_size > @limit ? @page + 1 : @page)
38
+ unless in_range? { fetched_size.positive? || @page == 1 }
39
+ assign_empty_page_variables
40
+ return self
47
41
  end
42
+
43
+ past = @last && @page < @last # current page is before the known last page
44
+ more = fetched_size > @limit # more pages after this one
45
+ @last = upto_max_pages(more ? @page + 1 : @page) unless past && more
48
46
  @in = [fetched_size, @limit].min
49
47
  @from = @in.zero? ? 0 : @offset + 1
50
48
  @to = @offset + @in
51
49
  assign_previous_and_next
50
+
52
51
  self
53
52
  end
53
+
54
+ # Called by false in_range?
55
+ def assign_empty_page_variables
56
+ @in = @from = @to = 0
57
+ target_last = [@page - 1, 1].max
58
+ @last = [@last || target_last, target_last].min
59
+ @previous = @last
60
+ end
61
+
62
+ # Support easy countless page param overriding (for legacy param and behavior)
63
+ def compose_page_param(page)
64
+ EscapedValue.new("#{page || 1}+#{@last}")
65
+ end
54
66
  end
55
67
  end
56
68
  end
@@ -9,20 +9,26 @@ class Pagy
9
9
  DEFAULT = { page: 1 }.freeze
10
10
 
11
11
  autoload :Countless, Pathname.new(__dir__).join('countless')
12
+ autoload :Countish, Pathname.new(__dir__).join('countish')
12
13
 
13
14
  include Rangeable
14
15
  include Shiftable
15
16
 
16
- def initialize(**) # rubocop:disable Lint/MissingSuper
17
+ def initialize(**)
17
18
  assign_options(**)
18
19
  assign_and_check(limit: 1, count: 0, page: 1)
19
20
  assign_last
20
21
  assign_offset
21
- return unless in_range? { @page <= @last }
22
+
23
+ unless in_range? { @page <= @last }
24
+ assign_empty_page_variables
25
+ return
26
+ end
22
27
 
23
28
  @from = [@offset + 1, @count].min
24
29
  @to = [@offset + @limit, @count].min
25
30
  @in = [@to - @from + 1, @count].min
31
+
26
32
  assign_previous_and_next
27
33
  end
28
34
 
@@ -3,14 +3,14 @@
3
3
  class Pagy
4
4
  module Search
5
5
  class Arguments < Array
6
- def respond_to_missing? = true
6
+ def respond_to_missing?(*) = true
7
7
 
8
8
  def method_missing(*) = push(*)
9
9
  end
10
10
 
11
11
  # Collect the search arguments to pass to the actual search
12
- def pagy_search(term = nil, **options, &block)
13
- Arguments.new([self, term, options, block])
12
+ def pagy_search(*arguments, **options, &block)
13
+ Arguments.new([self, arguments, options, block])
14
14
  end
15
15
  end
16
16
 
@@ -22,17 +22,13 @@ class Pagy
22
22
  def search? = true
23
23
  end
24
24
 
25
- class ElasticsearchRails < SearchBase
26
- # Get the count from different versions of ElasticsearchRails
27
- def self.total_count(results)
28
- total = results.instance_eval { respond_to?(:raw_response) ? raw_response['hits']['total'] : response['hits']['total'] }
29
- total.is_a?(Hash) ? total['value'] : total
30
- end
31
- end
25
+ class ElasticsearchRails < SearchBase; end
32
26
 
33
27
  class Meilisearch < SearchBase
34
28
  DEFAULT = { search_method: :ms_search }.freeze
35
29
  end
36
30
 
37
31
  class Searchkick < SearchBase; end
32
+
33
+ class TypesenseRails < SearchBase; end
38
34
  end
@@ -1,36 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Pagy
4
- # Decouple the reuest from the env, allowing non-rack apps to use pagy by passing a hash.
5
- # Resolve :page and :limit, supporting the :jsonapi option. Support for URL composition.
4
+ # Decouple the request from the env, allowing non-rack apps to use pagy by passing a hash.
5
+ # Resolve the :page and :limit options from params.
6
6
  class Request
7
- def initialize(request, options = {})
8
- @base_url, @path, @query, @cookie =
7
+ def initialize(options)
8
+ @options = options
9
+ request = @options[:request]
10
+ @base_url, @path, @params, @cookie =
9
11
  if request.is_a?(Hash)
10
- request.values_at(:base_url, :path, :query, :cookie)
12
+ request.values_at(:base_url, :path, :params, :cookie)
11
13
  else
12
- [request.base_url, request.path, request.GET, request.cookies['pagy']]
14
+ [request.base_url, request.path, get_params(request), request.cookies['pagy']]
13
15
  end
14
- @jsonapi = @query['page'] && options[:jsonapi]
15
- raise JsonapiReservedParamError, @query['page'] if @jsonapi && !@query['page'].respond_to?(:fetch)
16
+ freeze
16
17
  end
17
18
 
18
- attr_reader :base_url, :path, :query, :cookie
19
+ attr_reader :base_url, :path, :params, :cookie
19
20
 
20
- def resolve_page(options, force_integer: true)
21
- page_key = options[:page_key] || DEFAULT[:page_key]
22
- page = @jsonapi ? @query['page'][page_key] : @query[page_key]
23
- page = nil if page == '' # fix for app-generated queries like ?page=
24
- force_integer ? (page || 1).to_i : page
21
+ def resolve_page(force_integer: true)
22
+ page_key = @options[:page_key] || DEFAULT[:page_key]
23
+ page = @params.dig(@options[:root_key], page_key) || @params[page_key]
24
+ return [page.to_s.to_i, 1].max if force_integer
25
+
26
+ page unless page == ''
27
+ end
28
+
29
+ def resolve_limit
30
+ default = @options[:limit] || DEFAULT[:limit]
31
+ max_limit = @options[:client_max_limit]
32
+ return default unless max_limit
33
+
34
+ limit_key = @options[:limit_key] || DEFAULT[:limit_key]
35
+ limit = (@params.dig(@options[:root_key], limit_key) || @params[limit_key]).to_s.to_i
36
+ limit.zero? ? default : [limit, max_limit].min
25
37
  end
26
38
 
27
- def resolve_limit(options)
28
- limit_key = options[:limit_key] || DEFAULT[:limit_key]
29
- return options[:limit] || DEFAULT[:limit] \
30
- unless options[:client_max_limit] &&
31
- (requested_limit = @jsonapi ? @query['page'][limit_key] : @query[limit_key])
39
+ private
32
40
 
33
- [requested_limit.to_i, options[:client_max_limit]].min
41
+ def get_params(request)
42
+ request.GET.merge(request.POST).to_h.freeze
34
43
  end
35
44
  end
36
45
  end
data/lib/pagy/cli.rb ADDED
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'fileutils'
5
+ require 'rbconfig'
6
+ require_relative '../pagy'
7
+ require_relative '../../apps/index'
8
+
9
+ class Pagy
10
+ class CLI
11
+ HOST = 'localhost'
12
+ PORT = '8000'
13
+
14
+ def start(args = ARGV)
15
+ options = parse_options(args)
16
+ run_command(args, options)
17
+ end
18
+
19
+ private
20
+
21
+ def parse_options(args)
22
+ options = { env: 'development', host: HOST, port: PORT, quiet: false }
23
+
24
+ parser = OptionParser.new do |opts|
25
+ opts.banner = <<~BANNER
26
+ Pagy #{VERSION} (https://ddnexus.github.io/pagy/playground)
27
+ Playground to showcase, clone and develop Pagy APPs
28
+
29
+ Usage:
30
+ pagy APP [opts] Showcase APP from the installed gem
31
+ pagy clone APP Clone APP to the current dir
32
+ pagy FILE [opts] Develop app FILE from local path
33
+ BANNER
34
+
35
+ opts.summary_indent = ' '
36
+ opts.summary_width = 18
37
+
38
+ opts.separator "\nAPPs"
39
+ PagyApps::INDEX.each do |name, path|
40
+ desc = File.readlines(path)[3].sub('# ', '').strip
41
+ opts.separator " #{name.ljust(18)}#{desc}"
42
+ end
43
+
44
+ opts.separator "\nRackup options"
45
+ opts.on('-e', '--env ENV', 'Environment') { |v| options[:env] = v }
46
+ opts.on('-o', '--host HOST', 'Host') { |v| options[:host] = v }
47
+ opts.on('-p', '--port PORT', 'Port') { |v| options[:port] = v }
48
+ opts.on('-t', '--threads THREADS', 'Threads') { |v| options[:threads] = v }
49
+
50
+ opts.separator "\nOther options"
51
+ opts.on('-q', '--quiet', 'Quiet mode for development') { options[:quiet] = true }
52
+ opts.on('-v', '--version', 'Show version') do
53
+ puts VERSION
54
+ exit
55
+ end
56
+ opts.on('-h', '--help', 'Show this help') do
57
+ puts opts
58
+ exit
59
+ end
60
+
61
+ opts.separator "\nExamples"
62
+ opts.separator " pagy demo Showcase demo at http://#{HOST}:#{PORT}"
63
+ opts.separator ' pagy clone repro Clone repro to ./repro.ru (rename it)'
64
+ opts.separator " pagy ~/myapp.ru Develop ~/myapp.ru at #{HOST}:#{PORT}"
65
+ end
66
+
67
+ begin
68
+ parser.parse!(args)
69
+ rescue OptionParser::InvalidOption => e
70
+ abort e.message
71
+ end
72
+
73
+ if args.empty?
74
+ puts parser
75
+ exit
76
+ end
77
+
78
+ options
79
+ end
80
+
81
+ def run_command(args, options)
82
+ run_from_repo = Pagy::ROOT.join('pagy.gemspec').exist?
83
+ setup_gems(run_from_repo)
84
+
85
+ arg = args.shift
86
+
87
+ if arg.eql?('clone')
88
+ clone_app(args.shift)
89
+ else
90
+ serve_app(arg, options)
91
+ end
92
+ end
93
+
94
+ def clone_app(name)
95
+ abort "Expected APP to be in [#{PagyApps::INDEX.keys.join(', ')}]; got #{name.inspect}" unless PagyApps::INDEX.key?(name)
96
+
97
+ if File.exist?(name)
98
+ print "Do you want to overwrite the #{name.inspect} file? (y/n)> "
99
+ answer = gets.chomp
100
+ abort "#{name.inspect} file already present" unless answer.start_with?(/y/i)
101
+ end
102
+ FileUtils.cp(PagyApps::INDEX[name], '.', verbose: true)
103
+ end
104
+
105
+ def serve_app(arg, options)
106
+ if PagyApps::INDEX.key?(arg)
107
+ options[:env] = 'showcase'
108
+ options[:quiet] = true
109
+ # Avoid the creation of './tmp/local_secret.txt' for showcase env
110
+ ENV['SECRET_KEY_BASE'] = 'absolute secret!' if arg.eql?('rails')
111
+ file = PagyApps::INDEX[arg]
112
+ else
113
+ file = arg
114
+ end
115
+ abort "#{file.inspect} app not found" unless File.exist?(file)
116
+
117
+ gem_dir = File.expand_path('../..', __dir__)
118
+ rackup = "rackup -I #{gem_dir}/lib -r pagy -o #{options[:host]} -p #{options[:port]} -E #{options[:env]} #{file}"
119
+ rackup << " -O Threads=#{options[:threads]}" if options[:threads]
120
+ rackup << ' -q' if options[:quiet]
121
+
122
+ exec(rackup)
123
+ end
124
+
125
+ # Kept as a separate method because mocking 'gemfile' (dsl) is complex otherwise
126
+ def setup_gems(run_from_repo)
127
+ require 'bundler/inline'
128
+ gemfile(!run_from_repo) do
129
+ source 'https://rubygems.org'
130
+ gem 'logger'
131
+ gem 'rackup'
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Console loader
4
+
5
+ require 'pagy'
6
+ include Pagy::Console # rubocop:disable Style/MixinUsage
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Pagy
4
- # Add configurstion methods
4
+ # Add configuration methods
5
5
  module Configurable
6
6
  # Sync the pagy javascript targets
7
7
  def sync_javascript(destination, *targets)
@@ -15,7 +15,7 @@ class Pagy
15
15
  def dev_tools(wand_scale: 1)
16
16
  <<~HTML
17
17
  <script id="pagy-ai-widget">
18
- document.addEventListener('wand-positioned', #{ROOT.join('javascripts/ai_widget.js').read});
18
+ #{ROOT.join('javascripts/ai_widget.js').read}
19
19
  </script>
20
20
  <script id="pagy-wand" data-scale="#{wand_scale}">
21
21
  #{ROOT.join('javascripts/wand.js').read}
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Pagy
4
+ # Provide the helpers to count a collection
5
+ module Countable
6
+ module_function
7
+
8
+ # Get the collection count
9
+ def get_count(collection, options)
10
+ return collection.size if collection.instance_of?(Array)
11
+ return collection.count unless defined?(::ActiveRecord) && collection.is_a?(::ActiveRecord::Relation)
12
+
13
+ count = if options[:count_over] && !collection.group_values.empty?
14
+ # COUNT(*) OVER ()
15
+ sql = Arel.star.count.over(Arel::Nodes::Grouping.new([]))
16
+ collection.unscope(:order).pick(sql).to_i
17
+ else
18
+ collection.count(:all)
19
+ end
20
+
21
+ count.is_a?(Hash) ? count.size : count
22
+ end
23
+ end
24
+ end
@@ -3,31 +3,31 @@
3
3
  require 'uri'
4
4
 
5
5
  class Pagy
6
+ # Support spaces in placeholder params
7
+ class EscapedValue < String; end
8
+
6
9
  # Provide the helpers to handle the url and anchor
7
10
  module Linkable
8
11
  module QueryUtils
9
12
  module_function
10
13
 
11
14
  # Extracted from Rack::Utils and reformatted for rubocop
12
- # Add the 'unescaped' param, and use it for simple and safe url-templating.
13
- # All string keyed hashes
14
- def build_nested_query(value, prefix = nil, unescaped = [])
15
+ # Allow unescaped Pagy::RawQueryValue
16
+ def build_nested_query(value, prefix = nil)
15
17
  case value
16
18
  when Array
17
- value.map { |v| build_nested_query(v, "#{prefix}[]", unescaped) }.join('&')
19
+ value.map { |v| build_nested_query(v, "#{prefix}[]") }.join('&')
18
20
  when Hash
19
21
  value.map do |k, v|
20
- new_k = prefix ? "#{prefix}[#{escape(k)}]" : escape(k)
21
- unescaped[unescaped.find_index(k)] = new_k if unescaped.size.positive? && new_k != k && unescaped.include?(k)
22
- build_nested_query(v, new_k, unescaped)
22
+ build_nested_query(v, prefix ? "#{prefix}[#{k}]" : k)
23
23
  end.delete_if(&:empty?).join('&')
24
24
  when nil
25
25
  escape(prefix)
26
26
  else
27
27
  raise ArgumentError, 'value must be a Hash' if prefix.nil?
28
- return "#{escape(prefix)}=#{value}" if unescaped.include?(prefix)
29
28
 
30
- "#{escape(prefix)}=#{escape(value)}"
29
+ escaped_value = value.is_a?(EscapedValue) ? value : escape(value)
30
+ "#{escape(prefix)}=#{escaped_value}"
31
31
  end
32
32
  end
33
33
 
@@ -38,22 +38,33 @@ class Pagy
38
38
 
39
39
  protected
40
40
 
41
+ # Overridable by classes with composite page param
42
+ def compose_page_param(page) = page
43
+
41
44
  # Return the URL for the page, relying on the Pagy::Request
42
- def compose_page_url(page, limit_token: nil, **options)
43
- jsonapi, page_key, limit_key, limit, client_max_limit, querify, absolute, path, fragment =
44
- @options.merge(options)
45
- .values_at(:jsonapi, :page_key, :limit_key, :limit, :client_max_limit, :querify, :absolute, :path, :fragment)
46
- query = @request.query.clone(freeze: false)
47
- query.delete(jsonapi ? 'page' : page_key)
48
- factors = {}.tap do |h|
49
- h[page_key] = countless? ? "#{page || 1}+#{@last}" : page
50
- h[limit_key] = limit_token || limit if client_max_limit
51
- end.compact # No empty params
52
- query.merge!(jsonapi ? { 'page' => factors } : factors) if factors.size.positive?
53
- querify&.(query) # Must modify the query: the returned value is ignored
54
- query_string = QueryUtils.build_nested_query(query, nil, [page_key, limit_key])
55
- query_string = "?#{query_string}" unless query_string.empty?
56
- fragment &&= %(##{fragment}) unless fragment&.start_with?('#')
45
+ def compose_page_url(page, **options)
46
+ opts = @options.merge(options)
47
+ params = @request.params.clone(freeze: false)
48
+ root_key = opts[:root_key]
49
+ container = if root_key
50
+ params[root_key] = params[root_key]&.clone(freeze: false) || {}
51
+ else
52
+ params
53
+ end
54
+
55
+ { opts[:page_key] => compose_page_param(page),
56
+ opts[:limit_key] => opts[:client_max_limit] && opts[:limit] }.each do |k, v|
57
+ v ? container[k] = v : container.delete(k)
58
+ end
59
+
60
+ opts[:querify]&.(params) # Must modify the params: the returned value is ignored
61
+ fragment = opts[:fragment].to_s.sub(/\A(?=[^#])/, '#') # conditionally prepend '#'
62
+
63
+ compose_url(opts[:absolute], opts[:path], params, fragment)
64
+ end
65
+
66
+ def compose_url(absolute, path, params, fragment)
67
+ query_string = QueryUtils.build_nested_query(params).sub(/\A(?=.)/, '?') # conditionally prepend '?'
57
68
  "#{@request.base_url if absolute}#{path || @request.path}#{query_string}#{fragment}"
58
69
  end
59
70
  end
@@ -5,11 +5,11 @@ class Pagy
5
5
  module Rangeable
6
6
  # Check if in range
7
7
  def in_range?
8
- return @in_range if defined?(@in_range) || (@in_range = yield)
8
+ return @in_range if defined?(@in_range)
9
+ return true if (@in_range = yield)
9
10
  raise RangeError.new(self, :page, "in 1..#{@last}", @page) if @options[:raise_range_error]
10
11
 
11
- assign_empty_page_variables
12
- false
12
+ @in_range = false
13
13
  end
14
14
  end
15
15
  end
@@ -5,14 +5,19 @@ class Pagy
5
5
  module B64
6
6
  module_function
7
7
 
8
- def encode(bin) = [bin].pack('m0')
8
+ def encode(bin)
9
+ [bin].pack('m0')
10
+ end
9
11
 
10
- def decode(str) = str.unpack1('m0')
12
+ def decode(str)
13
+ str.unpack1('m0')
14
+ end
11
15
 
12
16
  def urlsafe_encode(bin)
13
17
  str = encode(bin)
14
- str.chomp!('==') or str.chomp!('=')
15
18
  str.tr!('+/', '-_')
19
+ str.delete!('=')
20
+
16
21
  str
17
22
  end
18
23
 
@@ -23,6 +28,7 @@ class Pagy
23
28
  else
24
29
  str = str.tr('-_', '+/')
25
30
  end
31
+
26
32
  decode(str)
27
33
  end
28
34
  end