jekyll_all_collections 0.3.7 → 0.4.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +19 -1
- data/CHANGELOG.md +30 -9
- data/README.md +208 -105
- data/jekyll_all_collections.gemspec +5 -9
- data/lib/hooks/a_page.rb +69 -0
- data/lib/hooks/all_collections_hooks.rb +59 -0
- data/lib/hooks/all_files.rb +46 -0
- data/lib/hooks/class_methods.rb +50 -0
- data/lib/jekyll_all_collections/version.rb +1 -1
- data/lib/jekyll_all_collections.rb +14 -2
- data/lib/tag/all_collections_tag.rb +158 -0
- data/lib/util/mslinn_binary_search.rb +152 -0
- data/lib/util/send_chain.rb +58 -0
- data/spec/all_collections_tag/all_collections_tag_sort_spec.rb +1 -0
- data/spec/bsearch_spec.rb +50 -0
- data/spec/date_sort_spec.rb +8 -6
- data/spec/mslinn_binary_search_spec.rb +47 -0
- data/spec/send_chain_spec.rb +72 -0
- data/spec/send_spec.rb +28 -0
- data/spec/sorted_lru_files_spec.rb +82 -0
- data/spec/spec_helper.rb +1 -3
- data/spec/status_persistence.txt +32 -22
- data/spec/testable_spec.rb +38 -0
- metadata +33 -8
- data/lib/all_collections_hooks.rb +0 -118
- data/lib/all_collections_tag.rb +0 -141
data/lib/hooks/a_page.rb
ADDED
@@ -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,5 +1,17 @@
|
|
1
|
-
|
2
|
-
|
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
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
# Ruby's binary search is unsuitable because ordering requirements are not stable.
|
4
|
+
# the value to be searched for changes the required ordering
|
5
|
+
|
6
|
+
RSpec.describe(Array) do
|
7
|
+
before { skip('Never gonna give you up/Never gonna let you down') }
|
8
|
+
|
9
|
+
sorted_ints = [0, 4, 7, 10, 12]
|
10
|
+
sorted_strings = %w[aaa aab aac bbb bbc bbd ccc ccd cce].sort.reverse
|
11
|
+
|
12
|
+
it 'returns index of first int match' do
|
13
|
+
actual = sorted_ints.bsearch_index { |x| x >= 4 }
|
14
|
+
expect(actual).to eq(1)
|
15
|
+
|
16
|
+
actual = sorted_ints.bsearch_index { |x| x >= 6 }
|
17
|
+
expect(actual).to eq(2)
|
18
|
+
|
19
|
+
actual = sorted_ints.bsearch_index { |x| x >= -1 }
|
20
|
+
expect(actual).to eq(0)
|
21
|
+
|
22
|
+
actual = sorted_ints.bsearch_index { |x| x >= 100 }
|
23
|
+
expect(actual).to be_nil
|
24
|
+
end
|
25
|
+
|
26
|
+
# See https://stackoverflow.com/q/79333097/553865
|
27
|
+
it 'returns index of first string match' do
|
28
|
+
puts(sorted_strings.map { |x| x.start_with? 'a' })
|
29
|
+
index = sorted_strings.bsearch_index { |x| x.start_with? 'a' }
|
30
|
+
expect(sorted_strings[index]).to eq('aac')
|
31
|
+
|
32
|
+
index = sorted_strings.bsearch_index { |x| x.start_with? 'aa' }
|
33
|
+
expect(sorted_strings[index]).to eq('aac')
|
34
|
+
|
35
|
+
index = sorted_strings.bsearch_index { |x| x.start_with? 'aaa' }
|
36
|
+
expect(sorted_strings[index]).to eq('aaa')
|
37
|
+
|
38
|
+
index = sorted_strings.bsearch_index { |x| x.start_with? 'b' }
|
39
|
+
expect(sorted_strings[index]).to eq('bbd')
|
40
|
+
|
41
|
+
index = sorted_strings.bsearch_index { |x| x.start_with? 'bb' }
|
42
|
+
expect(sorted_strings[index]).to eq('bbd')
|
43
|
+
|
44
|
+
index = sorted_strings.bsearch_index { |x| x.start_with? 'bbc' }
|
45
|
+
expect(sorted_strings[index]).to eq('bbc')
|
46
|
+
|
47
|
+
index = sorted_strings.bsearch_index { |x| x.start_with? 'cc' }
|
48
|
+
expect(sorted_strings[index]).to eq('cce')
|
49
|
+
end
|
50
|
+
end
|