binxtils 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +23 -0
- data/lib/binxtils/input_normalizer.rb +35 -0
- data/lib/binxtils/time_parser.rb +90 -0
- data/lib/binxtils/time_zone_parser.rb +109 -0
- data/lib/binxtils.rb +12 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6939fd30bf94c85805b8f5cbc55b0a5e4ea0b3938093c84b986e5d94571a9efb
|
|
4
|
+
data.tar.gz: 8cf7bcc61f2d001b6b4c67358b99505b528c5bfd40d9ac919c5a8b17a3efa64d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bc96e2f7f245112cfa3f70b1a224884c320b5cf27d17ce321d71e959935bf80127a113a9f96dfe762eebbb9122ed543589482cd8bc7e0a5f19c7f143f632d3b1
|
|
7
|
+
data.tar.gz: 0a1d915fb2ce58a04353421c0aed7ec6c41a56b0bdf56d07a146edad73ed26ead20548ca24eb6e6ccd01de9fdbd88ec0f63974b9f7bf4f6342e0eb2435ff9bfb
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bike Index
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Binxtils
|
|
2
|
+
|
|
3
|
+
Bike Index utility modules. Install it by adding the following line to your Gemfile
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
gem "binxtils"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Modules
|
|
10
|
+
|
|
11
|
+
- **Binxtils::InputNormalizer** - Sanitize and normalize user input strings
|
|
12
|
+
- **Binxtils::TimeParser** - Parse fuzzy time/date strings into `Time` objects
|
|
13
|
+
- **Binxtils::TimeZoneParser** - Parse and resolve time zone strings
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
All modules use [Functionable](https://github.com/sethherr/functionable) and are called as class methods:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
Binxtils::TimeParser.parse("next thursday")
|
|
21
|
+
Binxtils::InputNormalizer.string(" Some Input ")
|
|
22
|
+
Binxtils::TimeZoneParser.parse("Eastern Time")
|
|
23
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binxtils
|
|
4
|
+
module InputNormalizer
|
|
5
|
+
extend Functionable
|
|
6
|
+
|
|
7
|
+
def boolean(param = nil)
|
|
8
|
+
return false if param.blank?
|
|
9
|
+
|
|
10
|
+
ActiveRecord::Type::Boolean.new.cast(param.to_s.strip)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def string(val)
|
|
14
|
+
return nil if val.blank?
|
|
15
|
+
|
|
16
|
+
val.strip.gsub(/\s+/, " ")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def present_or_false?(val)
|
|
20
|
+
val.to_s.present?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sanitize(str = nil)
|
|
24
|
+
Rails::Html::Sanitizer.full_sanitizer.new.sanitize(str.to_s, encode_special_chars: true)
|
|
25
|
+
.strip
|
|
26
|
+
.gsub("&", "&") # ampersands are commonly used - keep them normal
|
|
27
|
+
.gsub(/\s+/, " ") # remove extra whitespace
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def regex_escape(val)
|
|
31
|
+
# Lazy hack, good enough for current purposes. Improve if required!
|
|
32
|
+
string(val)&.gsub(/\W/, ".")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binxtils
|
|
4
|
+
module TimeParser
|
|
5
|
+
extend Functionable
|
|
6
|
+
|
|
7
|
+
EARLIEST_YEAR = 1900
|
|
8
|
+
LATEST_YEAR = Time.current.year + 100
|
|
9
|
+
|
|
10
|
+
def default_time_zone
|
|
11
|
+
@default_time_zone ||= ActiveSupport::TimeZone[Rails.application.class.config.time_zone].freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(time_str = nil, time_zone_str = nil, in_time_zone: false)
|
|
15
|
+
return nil unless time_str.present?
|
|
16
|
+
return time_str if time_str.is_a?(Time)
|
|
17
|
+
|
|
18
|
+
if looks_like_timestamp?(time_str)
|
|
19
|
+
return parse("#{time_str}-01-01") if time_str.to_s.length == 4 # Looks like year, valid 8601 format
|
|
20
|
+
|
|
21
|
+
# otherwise it's a timestamp
|
|
22
|
+
time = Time.at(time_str.to_i)
|
|
23
|
+
else
|
|
24
|
+
time_zone = Binxtils::TimeZoneParser.parse(time_zone_str)
|
|
25
|
+
Time.zone = time_zone
|
|
26
|
+
time = Time.zone.parse(time_str.to_s) # Assign in time zone
|
|
27
|
+
Time.zone = default_time_zone
|
|
28
|
+
end
|
|
29
|
+
# Return in time_zone or not
|
|
30
|
+
in_time_zone ? time_in_zone(time, time_str:, time_zone:, time_zone_str:) : time
|
|
31
|
+
rescue ArgumentError => e
|
|
32
|
+
# Try to parse some other, unexpected formats -
|
|
33
|
+
paychex_formatted = %r{(?<month>\d+)/(?<day>\d+)/(?<year>\d+) (?<hour>\d\d):(?<minute>\d\d) (?<ampm>\w\w)}.match(time_str)
|
|
34
|
+
ie11_formatted = %r{(?<month>\d+)/(?<day>\d+)/(?<year>\d+)}.match(time_str)
|
|
35
|
+
just_date = %r{(?<year>\d{4})[^\d|\w](?<month>\d\d?)}.match(time_str)
|
|
36
|
+
just_date_backward = %r{(?<month>\d\d?)[^\d|\w](?<year>\d{4})}.match(time_str)
|
|
37
|
+
|
|
38
|
+
# Get the successful matching regex group, and then reformat it in an expected way
|
|
39
|
+
regex_match = [paychex_formatted, ie11_formatted, just_date, just_date_backward].compact.first
|
|
40
|
+
raise e unless regex_match.present?
|
|
41
|
+
|
|
42
|
+
new_str = %w[year month day]
|
|
43
|
+
.map { |component| regex_match[component] if regex_match.names.include?(component) }
|
|
44
|
+
.compact
|
|
45
|
+
.join("-")
|
|
46
|
+
|
|
47
|
+
# If we end up with an unreasonable year, throw an error
|
|
48
|
+
raise e unless new_str.split("-").first.to_i.between?(EARLIEST_YEAR, LATEST_YEAR)
|
|
49
|
+
|
|
50
|
+
# Add the day, if there isn't one
|
|
51
|
+
new_str += "-01" unless regex_match.names.include?("day")
|
|
52
|
+
# If it's paychex_formatted there is an hour and minute
|
|
53
|
+
if paychex_formatted.present?
|
|
54
|
+
new_str += " #{regex_match["hour"]}:#{regex_match["minute"]}#{regex_match["ampm"]}"
|
|
55
|
+
end
|
|
56
|
+
# Run it through Binxtils::TimeParser again
|
|
57
|
+
parse(new_str, time_zone_str, in_time_zone:)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def looks_like_timestamp?(time_str)
|
|
61
|
+
time_str.is_a?(Integer) || time_str.is_a?(Float) || time_str.to_s.strip.match(/^\d+\z/) # it's only numbers
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Accepts a time object, rounds to minutes
|
|
65
|
+
def round(time, unit = "minute")
|
|
66
|
+
if unit == "second"
|
|
67
|
+
time.change(usec: 0, sec: 0)
|
|
68
|
+
else # Default is minute, nothing is built to manage anything else
|
|
69
|
+
time.change(min: 0, usec: 0, sec: 0)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
#
|
|
74
|
+
# private below here
|
|
75
|
+
#
|
|
76
|
+
|
|
77
|
+
def time_in_zone(time, time_zone_str:, time_str: nil, time_zone: nil)
|
|
78
|
+
time_zone ||= if time_zone_str.present?
|
|
79
|
+
Binxtils::TimeZoneParser.parse(time_zone_str)
|
|
80
|
+
elsif time_str.present?
|
|
81
|
+
# If no time_zone_str was passed, try to parse it out of the time string
|
|
82
|
+
Binxtils::TimeZoneParser.parse_from_time_string(time_str.to_s)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
time.in_time_zone(time_zone || ActiveSupport::TimeZone["UTC"])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
conceal :time_in_zone
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binxtils
|
|
4
|
+
module TimeZoneParser
|
|
5
|
+
extend Functionable
|
|
6
|
+
|
|
7
|
+
def parse(time_zone_str)
|
|
8
|
+
return nil if time_zone_str.blank?
|
|
9
|
+
return time_zone_str if time_zone_str.is_a?(ActiveSupport::TimeZone) # in case we were given a time_zone obj
|
|
10
|
+
|
|
11
|
+
# tzinfo requires non-whitespaced strings.
|
|
12
|
+
# if the normal lookup fails, remove parens, then remove spaces and convert to underscores
|
|
13
|
+
ActiveSupport::TimeZone[time_zone_str] ||
|
|
14
|
+
ActiveSupport::TimeZone[time_zone_str.to_s.gsub(/\(.*?\)/, "").strip.tr("\s", "_")]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse_from_time_string(time_str)
|
|
18
|
+
return unless time_str.present? && time_string_has_zone_info?(time_str)
|
|
19
|
+
|
|
20
|
+
at = Time.parse(time_str.to_s)
|
|
21
|
+
offset_seconds = at.utc_offset
|
|
22
|
+
# Otherwise this returns casablanca. Guess UTC over London
|
|
23
|
+
return ActiveSupport::TimeZone["UTC"] if offset_seconds == 0
|
|
24
|
+
|
|
25
|
+
prioritized_zones_matching_offset(at, offset_seconds).first
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse_from_time_and_offset(time:, offset:)
|
|
29
|
+
offset_seconds = offset.is_a?(String) ? Time.zone_offset(offset) : offset
|
|
30
|
+
offset_seconds ||= offset.to_i # Fallback parsing of seconds in a string
|
|
31
|
+
|
|
32
|
+
prioritized_zones_matching_offset(time, offset_seconds).first
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def full_name(time_zone)
|
|
36
|
+
# TODO: figure out an easier way to get this
|
|
37
|
+
ActiveSupport::TimeZone::MAPPING.key(time_zone.tzinfo.name) || time_zone.name
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#
|
|
41
|
+
# private below here
|
|
42
|
+
#
|
|
43
|
+
|
|
44
|
+
# TODO: This might be overly complicated garbage
|
|
45
|
+
def time_string_has_zone_info?(time_str)
|
|
46
|
+
return false if Binxtils::TimeParser.looks_like_timestamp?(time_str)
|
|
47
|
+
|
|
48
|
+
timezone_patterns = [
|
|
49
|
+
/[+-]\d{2}:?\d{2}\b/, # +0900, +09:00, -0500, etc
|
|
50
|
+
/\b(?:UTC|GMT)\b/i, # UTC or GMT
|
|
51
|
+
/\b[A-Z]{3,4}\b/, # EST, PDT, AEST, etc
|
|
52
|
+
/[+-]\d{4}\b/, # +0900, -0500 without colon
|
|
53
|
+
/Z\b/, # UTC (Zulu time)
|
|
54
|
+
/\[[-+A-Za-z0-9\/]+\]/ # Time zone in brackets [America/New_York]
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# Try parsing to validate it's actually a time string
|
|
58
|
+
Time.parse(time_str)
|
|
59
|
+
|
|
60
|
+
# Check if any timezone pattern matches
|
|
61
|
+
timezone_patterns.any? { |pattern| time_str.match?(pattern) }
|
|
62
|
+
rescue ArgumentError
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Guess possible time zones based on UTC offset at a specific time
|
|
67
|
+
# Returns an array of [timezone_name, city_name] pairs
|
|
68
|
+
def prioritized_zones_matching_offset(at, offset_seconds)
|
|
69
|
+
# Convert seconds to hours for comparison
|
|
70
|
+
offset_hours = offset_seconds / 3600.0
|
|
71
|
+
|
|
72
|
+
# Get all time zones
|
|
73
|
+
possible_zones = ActiveSupport::TimeZone.all.select do |zone|
|
|
74
|
+
# Calculate the offset at the specific time
|
|
75
|
+
zone_time = at.in_time_zone(zone)
|
|
76
|
+
zone_offset = zone_time.utc_offset / 3600.0
|
|
77
|
+
|
|
78
|
+
zone_offset == offset_hours
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Sort zones by priority/popularity
|
|
82
|
+
prioritize_zones(possible_zones)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def prioritize_zones(zones)
|
|
86
|
+
zones.sort_by do |zone|
|
|
87
|
+
priority = case zone.tzinfo.name
|
|
88
|
+
# Major US zones
|
|
89
|
+
when /New_York/, /Chicago/, /Los_Angeles/ then 1
|
|
90
|
+
# Major European zones
|
|
91
|
+
when /London/, /Paris/, /Berlin/ then 5
|
|
92
|
+
# Major Asian zones
|
|
93
|
+
when /Tokyo/, /Shanghai/, /Singapore/ then 5
|
|
94
|
+
|
|
95
|
+
# Major cities generally
|
|
96
|
+
when /Mexico City|Sydney|Hong_Kong|Dubai|Toronto|Vancouver/ then 10
|
|
97
|
+
# State/Provincial capitals
|
|
98
|
+
when /Melbourne|Brisbane|Perth|Montreal|Edmonton/ then 20
|
|
99
|
+
# Smaller cities
|
|
100
|
+
else 100
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
[priority, zone]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
conceal :time_string_has_zone_info?, :prioritized_zones_matching_offset, :prioritize_zones
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/binxtils.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "functionable"
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_support/core_ext"
|
|
6
|
+
require "active_record"
|
|
7
|
+
require "loofah"
|
|
8
|
+
require "rails-html-sanitizer"
|
|
9
|
+
|
|
10
|
+
require_relative "binxtils/input_normalizer"
|
|
11
|
+
require_relative "binxtils/time_zone_parser"
|
|
12
|
+
require_relative "binxtils/time_parser"
|
metadata
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: binxtils
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Bike Index
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: functionable
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: activerecord
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: loofah
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rails-html-sanitizer
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
executables: []
|
|
83
|
+
extensions: []
|
|
84
|
+
extra_rdoc_files:
|
|
85
|
+
- LICENSE
|
|
86
|
+
- README.md
|
|
87
|
+
files:
|
|
88
|
+
- LICENSE
|
|
89
|
+
- README.md
|
|
90
|
+
- lib/binxtils.rb
|
|
91
|
+
- lib/binxtils/input_normalizer.rb
|
|
92
|
+
- lib/binxtils/time_parser.rb
|
|
93
|
+
- lib/binxtils/time_zone_parser.rb
|
|
94
|
+
homepage: https://github.com/bikeindex/binxtils
|
|
95
|
+
licenses:
|
|
96
|
+
- MIT
|
|
97
|
+
metadata:
|
|
98
|
+
bug_tracker_uri: https://github.com/bikeindex/binxtils/issues
|
|
99
|
+
homepage_uri: https://github.com/bikeindex/binxtils
|
|
100
|
+
funding_uri: https://github.com/sponsors/bikeindex
|
|
101
|
+
rubygems_mfa_required: 'true'
|
|
102
|
+
rdoc_options: []
|
|
103
|
+
require_paths:
|
|
104
|
+
- lib
|
|
105
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '3.4'
|
|
110
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - ">="
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: '0'
|
|
115
|
+
requirements: []
|
|
116
|
+
rubygems_version: 3.6.9
|
|
117
|
+
specification_version: 4
|
|
118
|
+
summary: Bike Index utility modules
|
|
119
|
+
test_files: []
|