jekyll_all_collections 0.3.6 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,19 +3,14 @@ require_relative 'lib/jekyll_all_collections/version'
3
3
  Gem::Specification.new do |spec|
4
4
  github = 'https://github.com/mslinn/jekyll_all_collections'
5
5
 
6
- spec.authors = ['Mike Slinn']
7
- spec.bindir = 'exe'
6
+ spec.authors = ['Mike Slinn']
8
7
  spec.description = <<~END_OF_DESC
9
- Provides a collection of all collections in site.all_collections.
8
+ Provides normalized collections and extra functionality for Jekyll websites.
10
9
  END_OF_DESC
11
- spec.email = ['mslinn@mslinn.com']
12
- spec.executables = []
13
-
14
- # Specify which files should be added to the gem when it is released.
15
- spec.files = Dir['.rubocop.yml', 'LICENSE.*', 'Rakefile', '{lib,spec}/**/*', '*.gemspec', '*.md']
16
-
10
+ spec.email = ['mslinn@mslinn.com']
11
+ spec.files = Dir['.rubocop.yml', 'LICENSE.*', 'Rakefile', '{lib,spec}/**/*', '*.gemspec', '*.md']
17
12
  spec.homepage = 'https://www.mslinn.com/jekyll_plugins/jekyll_all_collections.html'
18
- spec.license = 'MIT'
13
+ spec.license = 'MIT'
19
14
  spec.metadata = {
20
15
  'allowed_push_host' => 'https://rubygems.org',
21
16
  'bug_tracker_uri' => "#{github}/issues",
@@ -23,13 +18,15 @@ Gem::Specification.new do |spec|
23
18
  'homepage_uri' => spec.homepage,
24
19
  'source_code_uri' => github,
25
20
  }
26
- spec.name = 'jekyll_all_collections'
27
- spec.require_paths = ['lib']
21
+ spec.name = 'jekyll_all_collections'
22
+ spec.platform = Gem::Platform::RUBY
23
+ spec.require_paths = ['lib']
28
24
  spec.required_ruby_version = '>= 2.6.0'
29
- spec.summary = 'Provides a collection of all collections in site.all_collections.'
30
- spec.version = JekyllAllCollectionsVersion::VERSION
25
+ spec.summary = 'Provides normalized collections and extra functionality for Jekyll websites.'
26
+ spec.version = JekyllAllCollectionsVersion::VERSION
31
27
 
32
28
  spec.add_dependency 'jekyll', '>= 3.5.0'
33
29
  spec.add_dependency 'jekyll_draft'
34
30
  spec.add_dependency 'jekyll_plugin_support'
31
+ spec.add_dependency 'sorted_set'
35
32
  end
@@ -0,0 +1,69 @@
1
+ module AllCollectionsHooks
2
+ class APage
3
+ attr_reader :content, :data, :date, :description, :destination, :draft, :excerpt, :ext, :extname, :href,
4
+ :label, :last_modified, :layout, :origin, :path, :relative_path, :tags, :title, :type, :url
5
+
6
+ def initialize(obj, origin)
7
+ @origin = origin
8
+ data_field_init obj
9
+ obj_field_init obj
10
+ @draft = Jekyll::Draft.draft? obj
11
+ @href = @url if @href.nil?
12
+ # @href = "/#{@href}" if @origin == 'individual_page'
13
+ @href = "#{@href}index.html" if @href.end_with? '/'
14
+ @name = File.basename(@href)
15
+ @title = if @data&.key?('title')
16
+ @data['title']
17
+ elsif obj.respond_to?(:title)
18
+ obj.title
19
+ else
20
+ "<code>#{@href}</code>"
21
+ end
22
+ rescue StandardError => e
23
+ JekyllSupport.error_short_trace(@logger, e)
24
+ # JekyllSupport.warn_short_trace(@logger, e)
25
+ end
26
+
27
+ def to_s
28
+ @label || @date.to_s
29
+ end
30
+
31
+ private
32
+
33
+ def data_field_init(obj)
34
+ return unless obj.respond_to? :data
35
+
36
+ @data = obj.data
37
+
38
+ @categories = @data['categories'] if @data.key? 'categories'
39
+ @date = (@data['date'].to_date if @data&.key?('date')) || Date.today
40
+ @description = @data['description'] if @data.key? 'description'
41
+ @excerpt = @data['excerpt'] if @data.key? 'excerpt'
42
+ @ext ||= @data['ext'] if @data.key? 'ext'
43
+ @last_modified = @data['last_modified'] || @data['last_modified_at'] || @date
44
+ @last_modified_field = case @data
45
+ when @data.key?('last_modified')
46
+ 'last_modified'
47
+ when @data.key?('last_modified_at')
48
+ 'last_modified_at'
49
+ end
50
+ @layout = @data['layout'] if @data.key? 'layout'
51
+ @tags = @data['tags'] if @data.key? 'tags'
52
+ end
53
+
54
+ def obj_field_init(obj)
55
+ @content = obj.content if obj.respond_to? :content
56
+
57
+ # TODO: What _config.yml setting should be passed to destination()?
58
+ @destination = obj.destination('') if obj.respond_to? :destination
59
+ @ext = obj.extname
60
+ @extname = @ext # For compatibility with previous versions of all_collections
61
+ @label = obj.collection.label if obj.respond_to?(:collection) && obj.collection.respond_to?(:label)
62
+ @path = obj.path if obj.respond_to? :path
63
+ @relative_path = obj.relative_path if obj.respond_to? :relative_path
64
+ @type = obj.type if obj.respond_to? :type
65
+ @url = obj.url
66
+ @url = "#{@url}index.html" if @url.end_with? '/'
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,59 @@
1
+ module AllCollectionsHooks
2
+ class << self
3
+ attr_accessor :logger
4
+ end
5
+ @logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)
6
+
7
+ # No, all_collections is not defined for this hook
8
+ # Jekyll::Hooks.register(:site, :after_init, priority: :normal) do |site|
9
+ # defined_msg = AllCollectionsHooks.all_collections_defined?(site)
10
+ # @logger.debug { "Jekyll::Hooks.register(:site, :after_init: #{defined_msg}" }
11
+ # end
12
+
13
+ # Creates a `Array[APage]` property called site.all_collections if it does not already exist.
14
+ # The array is available from :site, :pre_render onwards
15
+ # Each `APage` entry is one document or page.
16
+ Jekyll::Hooks.register(:site, :post_read, priority: :normal) do |site|
17
+ @site = site
18
+ unless site.class.method_defined? :all_collections
19
+ defined_msg = AllCollectionsHooks.all_collections_defined? site
20
+ @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :normal: #{defined_msg}" }
21
+ AllCollectionsHooks.compute(site) if !@site.class.method_defined?(:all_documents) || @site.all_documents.nil?
22
+ end
23
+ rescue StandardError => e
24
+ JekyllSupport.error_short_trace(@logger, e)
25
+ # JekyllSupport.warn_short_trace(@logger, e)
26
+ end
27
+
28
+ # Yes, all_collections is defined for this hook
29
+ # Jekyll::Hooks.register(:site, :post_read, priority: :low) do |site|
30
+ # defined_msg = AllCollectionsHooks.all_collections_defined?(site)
31
+ # @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :low: #{defined_msg}" }
32
+ # rescue StandardError => e
33
+ # JekyllSupport.error_short_trace(@logger, e)
34
+ # # JekyllSupport.warn_short_trace(@logger, e)
35
+ # end
36
+
37
+ # Yes, all_collections is defined for this hook
38
+ # Jekyll::Hooks.register(:site, :post_read, priority: :normal) do |site|
39
+ # defined_msg = AllCollectionsHooks.all_collections_defined?(site)
40
+ # @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :normal: #{defined_msg}" }
41
+ # rescue StandardError => e
42
+ # JekyllSupport.error_short_trace(@logger, e)
43
+ # # JekyllSupport.warn_short_trace(@logger, e)
44
+ # end
45
+
46
+ # Yes, all_collections is defined for this hook
47
+ # Jekyll::Hooks.register(:site, :pre_render, priority: :normal) do |site, _payload|
48
+ # defined_msg = AllCollectionsHooks.all_collections_defined?(site)
49
+ # @logger.debug { "Jekyll::Hooks.register(:site, :pre_render: #{defined_msg}" }
50
+ # rescue StandardError => e
51
+ # JekyllSupport.error_short_trace(@logger, e)
52
+ # # JekyllSupport.warn_short_trace(@logger, e)
53
+ # end
54
+ end
55
+
56
+ PluginMetaLogger.instance.logger.info do
57
+ "Loaded AllCollectionsHooks v#{JekyllAllCollectionsVersion::VERSION} :site, :pre_render, :normal hook plugin."
58
+ end
59
+ Liquid::Template.register_filter(AllCollectionsHooks)
@@ -0,0 +1,46 @@
1
+ require_relative '../util/send_chain'
2
+
3
+ # Insert the url of a Jekyll::Page into each LruFile instance,
4
+ # along with the Page reference
5
+ # todo: replace references to url and :url with reverse_url and :reverse_url
6
+ LruFile = Struct.new(:url, :page) do
7
+ include SendChain
8
+
9
+ def <=>(other)
10
+ url <=> other.url
11
+ end
12
+ end
13
+
14
+ # Matches suffixes of an array of urls
15
+ # Converts suffixes to prefixes
16
+ class SortedLruFiles
17
+ attr_reader :msbs
18
+
19
+ def initialize
20
+ @msbs = MSlinnBinarySearch.new %i[url start_with?]
21
+ end
22
+
23
+ # @param apages [Array[APage]]
24
+ def add_pages(apages)
25
+ apages.each { |apage| insert apage.href, apage }
26
+ @msbs.enable_search
27
+ end
28
+
29
+ def enable_search
30
+ @msbs.enable_search
31
+ end
32
+
33
+ def find(suffix)
34
+ @msbs.find suffix
35
+ end
36
+
37
+ def insert(url, file)
38
+ lru_file = LruFile.new(url.reverse, file)
39
+ lru_file.new_chain [:url, %i[start_with? placeholder]]
40
+ @msbs.insert(lru_file)
41
+ end
42
+
43
+ def select(suffix)
44
+ @msbs.select_pages suffix
45
+ end
46
+ end
@@ -0,0 +1,50 @@
1
+ module AllCollectionsHooks
2
+ class << self
3
+ attr_accessor :all_collections, :all_documents, :everything, :sorted_lru_files
4
+ end
5
+
6
+ # @sort_by = ->(apages, criteria) { [apages.sort(criteria)] } # todo delete this
7
+
8
+ # @return [String] indicating if :all_collections is defined or not
9
+ def self.all_collections_defined?(site)
10
+ "site.all_collections #{site.class.method_defined?(:all_collections) ? 'IS' : 'IS NOT'} defined"
11
+ end
12
+
13
+ # Create Array of AllCollectionsHooks::APage from objects
14
+ # @param objects [Array] An array of Jekyll::Document, Jekyll::Page or file names
15
+ # @param origin [String] Indicates type of objects being passed
16
+ def self.apages_from_objects(objects, origin)
17
+ pages = []
18
+ objects.each do |object|
19
+ page = APage.new(object, origin)
20
+ pages << page unless page.data['exclude_from_all'] || page.path == 'redirect.html'
21
+ end
22
+ pages
23
+ end
24
+
25
+ # Called by early, high-priority hook.
26
+ # Computes site.all_collections, site.all_documents, site.everything, and site.sorted_lru_files
27
+ def self.compute(site)
28
+ site.class.module_eval { attr_accessor :all_collections, :all_documents, :everything, :sorted_lru_files }
29
+
30
+ documents = site.collections
31
+ .values
32
+ .map { |x| x.class.method_defined?(:docs) ? x.docs : x }
33
+ .flatten
34
+ .compact
35
+ @all_collections = AllCollectionsHooks.apages_from_objects(documents, 'collection')
36
+ @all_documents = @all_collections +
37
+ AllCollectionsHooks.apages_from_objects(site.pages, 'individual_page')
38
+ @everything = @all_documents +
39
+ AllCollectionsHooks.apages_from_objects(site.static_files, 'static_file')
40
+ @sorted_lru_files = SortedLruFiles.new.add_pages @everything
41
+
42
+ site.all_collections = @all_collections
43
+ site.all_documents = @all_documents
44
+ site.everything = @everything
45
+ site.sorted_lru_files = @sorted_lru_files
46
+ rescue StandardError => e
47
+ JekyllSupport.error_short_trace(AllCollectionsHooks.logger, e)
48
+ # JekyllSupport.warn_short_trace(AllCollectionsHooks.logger, e)
49
+ end
50
+ end
@@ -1,3 +1,3 @@
1
1
  module JekyllAllCollectionsVersion
2
- VERSION = '0.3.6'.freeze
2
+ VERSION = '0.4.0'.freeze
3
3
  end
@@ -1,5 +1,17 @@
1
- require_relative 'all_collections_hooks'
2
- require_relative 'all_collections_tag'
1
+ def require_directory(dir)
2
+ Dir[File.join(dir, '*.rb')]&.sort&.each do |file|
3
+ require file unless file == __FILE__
4
+ end
5
+ end
6
+
7
+ require 'jekyll'
8
+ require 'jekyll_plugin_logger'
9
+ require 'jekyll_plugin_support'
10
+
11
+ require_relative 'jekyll_all_collections/version'
12
+ require_directory "#{__dir__}/hooks"
13
+ require_directory "#{__dir__}/tag"
14
+ require_directory "#{__dir__}/util"
3
15
 
4
16
  module JekyllAllCollections
5
17
  include AllCollectionsHooks
@@ -0,0 +1,158 @@
1
+ require 'jekyll_draft'
2
+ require 'jekyll_plugin_support'
3
+ require 'securerandom'
4
+
5
+ # @author Copyright 2020 Michael Slinn
6
+ # @license SPDX-License-Identifier: Apache-2.0
7
+ module AllCollectionsTag
8
+ PLUGIN_NAME = 'all_collections'.freeze
9
+ CRITERIA = %w[date destination draft label last_modified last_modified_at path relative_path title type url].freeze
10
+ DRAFT_HTML = '<i class="jekyll_draft">Draft</i>'.freeze
11
+
12
+ class AllCollectionsTag < JekyllSupport::JekyllTag
13
+ include JekyllAllCollectionsVersion
14
+
15
+ # Method prescribed by JekyllTag.
16
+ # @return [String]
17
+ def render_impl
18
+ parse_arguments # Defines instance variables like @sort_by
19
+ sort_lambda = init_sort_by @sort_by, @sort_by_param
20
+ @heading = @helper.parameter_specified?('heading') || default_head(@sort_by)
21
+ generate_output sort_lambda
22
+ rescue StandardError => e
23
+ JekyllSupport.error_short_trace @logger, e
24
+ # JekyllSupport.warn_short_trace @logger, e
25
+ end
26
+
27
+ # Descending sort keys reverse the order of comparison
28
+ # Example return values:
29
+ # "->(a, b) { [a.last_modified] <=> [b.last_modified] }"
30
+ # "->(a, b) { [b.last_modified] <=> [a.last_modified] }" (descending)
31
+ # "->(a, b) { [a.last_modified, a.date] <=> [b.last_modified, b.date] }" (descending last_modified, ascending date)
32
+ # "->(a, b) { [a.last_modified, b.date] <=> [b.last_modified, a.date] }" (ascending last_modified, descending date)
33
+ def self.create_lambda_string(criteria)
34
+ criteria_lhs_array = []
35
+ criteria_rhs_array = []
36
+ verify_sort_by_type(criteria).each do |c|
37
+ descending_sort = c.start_with? '-'
38
+ c.delete_prefix! '-'
39
+ abort("Error: '#{c}' is not a valid sort field. Valid field names are: #{CRITERIA.join ', '}") \
40
+ unless CRITERIA.include?(c)
41
+ criteria_lhs_array << (descending_sort ? "b.#{c}" : "a.#{c}")
42
+ criteria_rhs_array << (descending_sort ? "a.#{c}" : "b.#{c}")
43
+ end
44
+ "->(a, b) { [#{criteria_lhs_array.join(', ')}] <=> [#{criteria_rhs_array.join(', ')}] }"
45
+ end
46
+
47
+ def self.verify_sort_by_type(sort_by)
48
+ case sort_by
49
+ when Array
50
+ sort_by
51
+ when Enumerable
52
+ sort_by.to_a
53
+ when Date
54
+ [sort_by.to_i]
55
+ when String
56
+ [sort_by]
57
+ else
58
+ abort "Error: @sort_by was specified as '#{sort_by}'; it must either be a string or an array of strings"
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def default_head(sort_by)
65
+ criteria = (sort_by.map do |x|
66
+ reverse = x.start_with? '-'
67
+ criterion = x.delete_prefix('-').capitalize
68
+ criterion += reverse ? ' (Newest to Oldest)' : ' (Oldest to Newest)'
69
+ criterion
70
+ end).join(', ')
71
+ "All Posts in All Categories Sorted By #{criteria}"
72
+ end
73
+
74
+ def evaluate(string)
75
+ self.eval string, binding, __FILE__, __LINE__
76
+ rescue StandardError => e
77
+ warn_short_trace e.red
78
+ end
79
+
80
+ def last_modified_value(apage)
81
+ @logger.debug do
82
+ " apage.last_modified='#{apage.last_modified}'; " \
83
+ "apage.last_modified_at='#{apage.last_modified_at}'; " \
84
+ "@date_column='#{@date_column}'"
85
+ end
86
+ last_modified = if @date_column == 'last_modified' && apage.respond_to?(:last_modified)
87
+ apage.last_modified
88
+ elsif apage.respond_to? :last_modified_at
89
+ apage.last_modified_at
90
+ else
91
+ apage.date
92
+ end
93
+ last_modified ||= apage.date || Date.today
94
+ last_modified
95
+ end
96
+
97
+ def generate_output(sort_lambda)
98
+ id = @id.to_s.strip.empty? ? '' : " id='#{@id}'"
99
+ heading = @heading.strip.to_s.empty? ? '' : "<h2#{id}>#{@heading}</h2>"
100
+ data = case @data_selector
101
+ when 'all_collections'
102
+ @site.all_collections
103
+ when 'all_documents'
104
+ @site.all_documents
105
+ when 'everything'
106
+ @site.everything
107
+ else
108
+ raise AllCollectionsError, "Invalid value for @data_selector (#{data_selector})"
109
+ end
110
+ collection = data.sort(&sort_lambda)
111
+ posts = collection.map do |x|
112
+ last_modified = last_modified_value x
113
+ date = last_modified.strftime '%Y-%m-%d'
114
+ draft = x.draft ? DRAFT_HTML : ''
115
+ href = "<a href='#{x.href}'>#{x.title}</a>"
116
+ @logger.debug { " date='#{date}' #{x.title}\n" }
117
+ " <span>#{date}</span><span>#{href}#{draft}</span>"
118
+ end
119
+ <<~END_TEXT
120
+ #{heading}
121
+ <div class="posts">
122
+ #{posts.join "\n"}
123
+ </div>
124
+ END_TEXT
125
+ rescue ArgumentError => e
126
+ warn_short_trace e
127
+ end
128
+
129
+ # See https://stackoverflow.com/a/75377832/553865
130
+ def init_sort_by(sort_by, sort_by_param)
131
+ sort_lambda_string = AllCollectionsTag.create_lambda_string sort_by
132
+
133
+ @logger.debug do
134
+ "#{@page['path']} sort_by_param=#{sort_by_param} " \
135
+ "sort_lambda_string = #{sort_lambda_string}\n"
136
+ end
137
+
138
+ evaluate sort_lambda_string
139
+ end
140
+
141
+ def parse_arguments
142
+ @data_selector = @helper.parameter_specified?('data_selector') || 'all_collections'
143
+ abort "Invalid data_selector #{@data_selector}" unless %w[all_collections all_documents everything].include? @data_selector
144
+
145
+ @date_column = @helper.parameter_specified?('date_column') || 'date'
146
+ unless %w[date last_modified].include?(@date_column)
147
+ raise AllCollectionsError "The date_column attribute must either have value 'date' or 'last_modified', " \
148
+ "but '#{@date_column}' was specified"
149
+ end
150
+
151
+ @id = @helper.parameter_specified?('id') || SecureRandom.hex(10)
152
+ @sort_by_param = @helper.parameter_specified? 'sort_by'
153
+ @sort_by = (@sort_by_param&.delete(' ')&.split(',') if @sort_by_param != false) || ['-date']
154
+ end
155
+
156
+ JekyllSupport::JekyllPluginHelper.register(self, PLUGIN_NAME)
157
+ end
158
+ end
@@ -0,0 +1,152 @@
1
+ unless defined?(MSlinnBinarySearchError)
2
+ class MSlinnBinarySearchError < StandardError
3
+ end
4
+ end
5
+
6
+ # Ruby's binary search is unsuitable because the value to be searched for changes the required ordering for String compares
7
+ class MSlinnBinarySearch
8
+ attr_reader :accessor_chain, :array # For testing only
9
+
10
+ def initialize(accessor_chain)
11
+ @array = SortedSet.new # [LruFile] Ordered highest to lowest
12
+ @accessor_chain = accessor_chain
13
+ end
14
+
15
+ # Convert the SortedSet to an Array
16
+ def enable_search
17
+ @array = @array.to_a
18
+ end
19
+
20
+ # A match is found when the Array[LruFile] has an href which starts with the given stem
21
+ # @param stem [String]
22
+ # @return first item from @array.url that matches, or nil if no match
23
+ def find(stem)
24
+ raise MSlinnBinarySearchError, 'Invalid find because stem to search for is nil.' if stem.nil?
25
+
26
+ index = find_index(stem)
27
+ return nil if index.nil?
28
+
29
+ @array[index]
30
+ end
31
+
32
+ # @param stem [String]
33
+ # @return index of first matching stem, or nil if @array is empty, or 0 if no stem specified
34
+ def find_index(stem)
35
+ return nil if @array.empty?
36
+ return 0 if stem.nil? || stem.empty?
37
+
38
+ mets = stem.reverse
39
+ return nil if @array[0].url[0...mets.size] > mets # TODO: use chain eval for item
40
+ return nil if @array[0].url[0] != mets[0]
41
+
42
+ _find_index(mets, 0, @array.length - 1)
43
+ end
44
+
45
+ # @param stem [String]
46
+ # @return [index] of matching values, or [] if @array is empty, or entire array if no stem specified
47
+ def find_indices(stem)
48
+ return [] if @array.empty?
49
+ return @array if stem.nil? || stem.empty?
50
+
51
+ first_index = _find_index(stem, 0, @array.length - 1)
52
+ last_index = first_index
53
+ last_index += 1 while @array[last_index].url.start_with? stem
54
+ [first_index..last_index]
55
+ end
56
+
57
+ # @param item [LruFile]
58
+ # @return [int] index of matching LruFile in @array, or nil if not found
59
+ def index_of(lru_file)
60
+ raise MSlinnBinarySearchError, 'Invalid index_of lru_file (nil).' if lru_file.nil?
61
+
62
+ find_index lru_file.url
63
+ end
64
+
65
+ # @return [LruFile] item at given index in @array
66
+ def item_at(index)
67
+ if index > @array.length - 1
68
+ raise MSlinnBinarySearchError,
69
+ "Invalid item_at index (#{index}) is greater than maximum stem (#{@array.length - 1})."
70
+ end
71
+ raise MSlinnBinarySearchError, "Invalid item_at index (#{index}) is less than zero." if index.negative?
72
+
73
+ @array[index]
74
+ end
75
+
76
+ # @param lru_file [LruFile]
77
+ def insert(lru_file)
78
+ raise MSlinnBinarySearchError, 'Invalid insert because new item is nil.' if lru_file.nil?
79
+ raise MSlinnBinarySearchError, "Invalid insert because new item has no chain (#{lru_file})" if lru_file.chain.nil?
80
+
81
+ @array.add lru_file
82
+ end
83
+
84
+ # TODO: Cache this method
85
+ # @param suffix [String] to use stem search on
86
+ # @return nil if @array is empty
87
+ # @return the first item in @array if suffix is nil or an empty string
88
+ def prefix_search(suffix)
89
+ return nil if @array.empty?
90
+ return @array[0] if suffix.empty? || suffix.nil?
91
+
92
+ low = search_index { |x| x.evaluate_with suffix }
93
+ return [] if low.nil?
94
+
95
+ high = low
96
+ high += 1 while high < @array.length &&
97
+ @array[high].evaluate_with(suffix)
98
+ @array[low..high]
99
+ end
100
+
101
+ # @param stem [String]
102
+ # @return [APage] matching APages, or [] if @array is empty, or entire array if no stem specified
103
+ def select_pages(stem)
104
+ first_index = find_index stem
105
+ return [] if first_index.nil?
106
+
107
+ last_index = first_index
108
+ while last_index < @array.length - 1
109
+ # LruFile.url is reversed, bug LruFile.page is not
110
+ break unless @array[last_index + 1].url.start_with?(stem.reverse)
111
+
112
+ last_index += 1
113
+ end
114
+ Range.new(first_index, last_index).map { |i| @array[i].page }
115
+ end
116
+
117
+ private
118
+
119
+ # A match is found when the Array[LruFile] has an href which starts with the given stem
120
+ # @param stem [String]
121
+ # @return [int] first index in @array that matches, or nil if no match
122
+ def _find_index(mets, min_index, max_index)
123
+ raise MSlinnBinarySearchError, "_find_index min_index(#{min_index})<0" if min_index.negative?
124
+ raise MSlinnBinarySearchError, "_find_index min_index(#{min_index})>max_index(#{max_index})" if min_index > max_index
125
+ raise MSlinnBinarySearchError, "_find_index max_index(#{max_index})>=@array.length(#{@array.length})" if max_index >= @array.length
126
+
127
+ return min_index if (min_index == max_index) && @array[min_index].url.start_with?(mets)
128
+
129
+ while min_index < max_index
130
+ mid_index = (min_index + max_index) / 2
131
+ mid_value = @array[mid_index].url[0...(mets.size)] # TODO: use chain eval for item
132
+
133
+ if mid_value == mets # back up until the first match is found
134
+ index = mid_index
135
+ loop do
136
+ return 0 if index.zero?
137
+
138
+ return index unless @array[index - 1].url.start_with?(mets)
139
+
140
+ index -= 1
141
+ end
142
+ elsif mid_value > mets
143
+ max_index = mid_index - 1
144
+ return _find_index(mets, min_index, max_index)
145
+ else
146
+ min_index = mid_index + 1
147
+ return _find_index(mets, min_index, max_index)
148
+ end
149
+ end
150
+ nil
151
+ end
152
+ end
@@ -0,0 +1,58 @@
1
+ require_relative '../util/mslinn_binary_search'
2
+
3
+ # Supports one chain at a time
4
+ module SendChain
5
+ # See https://stackoverflow.com/a/79333706/553865
6
+ # This method can be called directly if no methods in the chain require arguments
7
+ # Does not use any external state
8
+ def send_chain(chain)
9
+ Array(chain).inject(self) { |o, a| o.send(*a) }
10
+ end
11
+
12
+ # Saves @chain structure containing :placeholders for arguments to be supplied later
13
+ # Call when a different chain with :placeholders is desired
14
+ def new_chain(chain)
15
+ abort "new_chain error: chain must be an array ('#{chain}' was an #{chain.class.name})" \
16
+ unless chain.instance_of?(Array)
17
+ @chain = chain
18
+ end
19
+
20
+ # Call after new_chain, to evaluate @chain with values
21
+ def substitute_and_send_chain_with(values)
22
+ send_chain substitute_chain_with values
23
+ end
24
+
25
+ alias evaluate_with substitute_and_send_chain_with
26
+
27
+ # Call this method after calling new_chain to perform error checking and replace :placeholders with values.
28
+ # @chain is not modified.
29
+ # @return [Array] Modified chain
30
+ def substitute_chain_with(values)
31
+ values = [values] unless values.instance_of?(Array)
32
+
33
+ placeholder_count = @chain.flatten.count { |x| x == :placeholder }
34
+ if values.length != placeholder_count
35
+ abort "with_values error: number of values (#{values.length}) does not match the number of placeholders (#{placeholder_count})"
36
+ end
37
+
38
+ eval_chain @chain, values
39
+ end
40
+
41
+ private
42
+
43
+ # Replaces :placeholders with values
44
+ # Does not use any external state
45
+ # @return modified chain
46
+ def eval_chain(chain, values)
47
+ chain.map do |c|
48
+ case c
49
+ when :placeholder
50
+ values.shift
51
+ when Array
52
+ eval_chain c, values
53
+ else
54
+ c
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,4 +1,5 @@
1
1
  require 'spec_helper'
2
+ require_relative '../../lib/jekyll_all_collections'
2
3
 
3
4
  class APageStub
4
5
  attr_reader :date, :last_modified, :label