aws-cron-parser 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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +117 -0
  5. data/lib/aws_cron_parser/describer/cron_composer.rb +198 -0
  6. data/lib/aws_cron_parser/describer/day_composer.rb +74 -0
  7. data/lib/aws_cron_parser/describer/field_describers/base.rb +108 -0
  8. data/lib/aws_cron_parser/describer/field_describers/day_of_month.rb +88 -0
  9. data/lib/aws_cron_parser/describer/field_describers/day_of_week.rb +143 -0
  10. data/lib/aws_cron_parser/describer/field_describers/month.rb +92 -0
  11. data/lib/aws_cron_parser/describer/field_describers/time_field.rb +101 -0
  12. data/lib/aws_cron_parser/describer/month_composer.rb +29 -0
  13. data/lib/aws_cron_parser/describer/template.en.yml +183 -0
  14. data/lib/aws_cron_parser/describer/template.rb +87 -0
  15. data/lib/aws_cron_parser/describer/time_composer.rb +255 -0
  16. data/lib/aws_cron_parser/describer.rb +131 -0
  17. data/lib/aws_cron_parser/fugit_adapter.rb +64 -0
  18. data/lib/aws_cron_parser/helpers/calendar.rb +115 -0
  19. data/lib/aws_cron_parser/helpers/clock.rb +50 -0
  20. data/lib/aws_cron_parser/helpers/cron_expression.rb +277 -0
  21. data/lib/aws_cron_parser/helpers/numbers.rb +32 -0
  22. data/lib/aws_cron_parser/helpers.rb +17 -0
  23. data/lib/aws_cron_parser/parser.rb +152 -0
  24. data/lib/aws_cron_parser/parsers/aws_at.rb +32 -0
  25. data/lib/aws_cron_parser/parsers/aws_cron.rb +413 -0
  26. data/lib/aws_cron_parser/parsers/aws_rate.rb +33 -0
  27. data/lib/aws_cron_parser/parsers/base.rb +21 -0
  28. data/lib/aws_cron_parser/parsers/quartz_cron.rb +21 -0
  29. data/lib/aws_cron_parser/validator.rb +31 -0
  30. data/lib/aws_cron_parser/validators/aws_at_validator.rb +28 -0
  31. data/lib/aws_cron_parser/validators/aws_cron_validator.rb +212 -0
  32. data/lib/aws_cron_parser/validators/aws_macro_validator.rb +13 -0
  33. data/lib/aws_cron_parser/validators/aws_rate_validator.rb +17 -0
  34. data/lib/aws_cron_parser/validators/base.rb +23 -0
  35. data/lib/aws_cron_parser/validators/quartz_cron_validator.rb +132 -0
  36. data/lib/aws_cron_parser/version.rb +5 -0
  37. data/lib/aws_cron_parser.rb +48 -0
  38. metadata +127 -0
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module AWSCronParser
6
+ module Describer
7
+ module FieldDescribers
8
+ # Describes day-of-week field
9
+ class DayOfWeek < Base
10
+ NTH_PATTERN = /^(?<dow>\d|[A-Z]{3})#(?<occurrence>\d)$/i
11
+ LAST_PATTERN = /^(?<dow>\d|[A-Z]{3})L$/i
12
+ # Override to accept day names like MON-FRI
13
+ RANGE_PATTERN = /^(?<start>\d+|[A-Z]{3})-(?<end>\d+|[A-Z]{3})$/i
14
+ # Override to accept a named base like MON/2, which Base's numeric-only
15
+ # pattern rejects, leaving the raw field in the description.
16
+ STEP_PATTERN = %r{^(?:(?<base>\*|\d+|[A-Z]{3})|(?<start>\d+|[A-Z]{3})-(?<end>\d+|[A-Z]{3}))/(?<step>\d+)$}i
17
+ LIST_PATTERN = /^([\d,]+|[A-Z]{3}(,[A-Z]{3})*)$/i
18
+
19
+ DAY_ABBR_TO_NAME = {
20
+ 'SUN' => 'Sunday', 'MON' => 'Monday', 'TUE' => 'Tuesday',
21
+ 'WED' => 'Wednesday', 'THU' => 'Thursday', 'FRI' => 'Friday', 'SAT' => 'Saturday'
22
+ }.freeze
23
+
24
+ def describe
25
+ return nil if wildcard?
26
+
27
+ # AWS-specific patterns
28
+ if is_aws
29
+ return describe_nth_weekday if value.match?(NTH_PATTERN)
30
+ return describe_last_weekday if value.match?(LAST_PATTERN)
31
+ end
32
+
33
+ super # Use base class patterns (range, list, single)
34
+ end
35
+
36
+ private
37
+
38
+ def describe_nth_weekday
39
+ match = value.match(NTH_PATTERN)
40
+ dow_value = match[:dow]
41
+ occurrence = match[:occurrence].to_i
42
+
43
+ day_text = parse_day_name(dow_value)
44
+ nth_word = Template.ordinal_word(occurrence)
45
+
46
+ Template.interpolate('day_of_week.nth_occurrence', nth_word: nth_word, day_name: day_text)
47
+ end
48
+
49
+ def describe_last_weekday
50
+ match = value.match(LAST_PATTERN)
51
+ dow_value = match[:dow]
52
+
53
+ day_text = parse_day_name(dow_value)
54
+ Template.interpolate('day_of_week.last_occurrence', day_name: day_text)
55
+ end
56
+
57
+ def describe_step
58
+ match = value.match(STEP_PATTERN)
59
+ return nil unless match
60
+
61
+ step = match[:step].to_i
62
+ base = match[:base] || match[:start]
63
+
64
+ base == '*' ? step_wildcard(step) : step_from(base, step)
65
+ end
66
+
67
+ def step_wildcard(step)
68
+ Template.interpolate('day_of_week.step', step: step)
69
+ end
70
+
71
+ def step_from(start_val, step)
72
+ Template.interpolate('day_of_week.step_from', step: step, start_day: parse_day_name(start_val))
73
+ end
74
+
75
+ def describe_range
76
+ return nil unless value.match?(RANGE_PATTERN)
77
+
78
+ match = value.match(RANGE_PATTERN)
79
+ start_val = match[:start]
80
+ end_val = match[:end]
81
+
82
+ # Handle both numeric and name ranges (e.g., "MON-FRI")
83
+ start_name = parse_day_name(start_val)
84
+ end_name = parse_day_name(end_val)
85
+
86
+ Template.interpolate('day_of_week.range', start_day: start_name, end_day: end_name)
87
+ end
88
+
89
+ # Override to handle lists like "MON,WED,FRI"
90
+ def describe_list
91
+ return nil unless value.include?(',') && value.match?(LIST_PATTERN)
92
+
93
+ items = value.split(',').map(&:strip)
94
+ formatted = items.map { |v| parse_day_name(v) }
95
+ list_template(formatted)
96
+ end
97
+
98
+ def list_template(items)
99
+ Template.interpolate('day_of_week.list', items: format_list(items))
100
+ end
101
+
102
+ def range_template(start_val, end_val)
103
+ start_name = parse_day_name(start_val)
104
+ end_name = parse_day_name(end_val)
105
+ Template.interpolate('day_of_week.range', start_day: start_name, end_day: end_name)
106
+ end
107
+
108
+ # Base coerces the value with to_i before formatting, which collapses a
109
+ # name like FRI to 0. single_template already accepts either form.
110
+ def describe_single
111
+ single_template(value)
112
+ end
113
+
114
+ def single_template(val)
115
+ day_name = parse_day_name(val)
116
+ Template.interpolate('day_of_week.single', day_name: day_name)
117
+ end
118
+
119
+ def format_value(val)
120
+ parse_day_name(val)
121
+ end
122
+
123
+ def parse_day_name(val)
124
+ return day_name(val, is_aws: is_aws) if val.to_s.match?(/^\d+$/)
125
+ return DAY_ABBR_TO_NAME[val.to_s.upcase] if val.to_s.match?(/^[A-Z]{3}$/i)
126
+
127
+ val
128
+ end
129
+
130
+ def format_list(items)
131
+ return items.first if items.length == 1
132
+
133
+ if items.length == 2
134
+ items.join(' and ')
135
+ else
136
+ last = items.pop
137
+ "#{items.join(', ')}, and #{last}"
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module AWSCronParser
6
+ module Describer
7
+ module FieldDescribers
8
+ # Describes month field
9
+ class Month < Base
10
+ def describe
11
+ return nil if wildcard?
12
+
13
+ # Normalize abbreviations to numbers first
14
+ @value = normalize_month_value(value)
15
+
16
+ super
17
+ end
18
+
19
+ private
20
+
21
+ def step_wildcard(step)
22
+ Template.interpolate('month.step',
23
+ step: step,
24
+ step_plural: Template.plural_for(step),
25
+ start_month: month_name(1))
26
+ end
27
+
28
+ def step_from(start_val, step)
29
+ Template.interpolate('month.step_range',
30
+ step: step,
31
+ step_plural: Template.plural_for(step),
32
+ start_month: month_name(start_val))
33
+ end
34
+
35
+ def list_template(items)
36
+ Template.interpolate('month.list', items: format_list(items))
37
+ end
38
+
39
+ # Base already ran the value through format_value, so month_name has
40
+ # been applied; calling it again would turn "January" into "month January".
41
+ def range_template(start_val, end_val)
42
+ Template.interpolate('month.range', start_month: start_val, end_month: end_val)
43
+ end
44
+
45
+ def single_template(val)
46
+ Template.interpolate('month.single', month_name: val)
47
+ end
48
+
49
+ def format_value(val)
50
+ month_name(val.to_i)
51
+ end
52
+
53
+ def normalize_month_value(val)
54
+ return val if val == '*' || val.match?(/^\d/)
55
+
56
+ # Handle ranges
57
+ if val.include?('-')
58
+ parts = val.split('-')
59
+ return "#{month_abbr_to_num(parts[0])}-#{month_abbr_to_num(parts[1])}"
60
+ end
61
+
62
+ # Handle lists
63
+ if val.include?(',')
64
+ parts = val.split(',').map { |m| month_abbr_to_num(m) }
65
+ return parts.join(',')
66
+ end
67
+
68
+ # Handle steps
69
+ if val.include?('/')
70
+ parts = val.split('/')
71
+ base = parts[0] == '*' ? '*' : month_abbr_to_num(parts[0])
72
+ return "#{base}/#{parts[1]}"
73
+ end
74
+
75
+ # Single month abbreviation
76
+ month_abbr_to_num(val).to_s
77
+ end
78
+
79
+ def format_list(items)
80
+ return items.first if items.length == 1
81
+
82
+ if items.length == 2
83
+ items.join(' and ')
84
+ else
85
+ last = items.pop
86
+ "#{items.join(', ')}, and #{last}"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module AWSCronParser
6
+ module Describer
7
+ module FieldDescribers
8
+ # Describes time fields (second, minute, hour)
9
+ class TimeField < Base
10
+ attr_reader :field_type
11
+
12
+ def initialize(value, field_type, is_aws: false)
13
+ super(value, is_aws: is_aws)
14
+ @field_type = field_type # :second, :minute, or :hour
15
+ end
16
+
17
+ private
18
+
19
+ def step_wildcard(step)
20
+ Template.interpolate("#{field_type}.step", step: step, step_plural: Template.plural_for(step))
21
+ end
22
+
23
+ def step_from(start_val, step)
24
+ context = { step: step, step_plural: Template.plural_for(step) }
25
+
26
+ if field_type == :hour
27
+ context[:start_hour] = format_hour_12(start_val)
28
+ Template.interpolate('hour.step_range', **context)
29
+ else
30
+ context[:start] = start_val
31
+ Template.interpolate("#{field_type}.step_range", **context)
32
+ end
33
+ end
34
+
35
+ def step_range(start_val, end_val, step)
36
+ context = { step: step, step_plural: Template.plural_for(step) }
37
+
38
+ if field_type == :hour
39
+ context[:start_hour] = format_hour_12(start_val)
40
+ context[:end_hour] = format_hour_12(end_val)
41
+ Template.interpolate('hour.step_between', **context)
42
+ else
43
+ context[:start] = start_val
44
+ context[:end] = end_val
45
+ Template.interpolate("#{field_type}.step_between", **context)
46
+ end
47
+ end
48
+
49
+ def list_template(items)
50
+ list_plural = items.length > 1 ? 's' : ''
51
+ Template.interpolate("#{field_type}.list", items: format_list(items), list_plural: list_plural)
52
+ end
53
+
54
+ def range_template(start_val, end_val)
55
+ if field_type == :hour
56
+ Template.interpolate('hour.range', start_hour: format_hour_12(start_val), end_hour: format_hour_12(end_val))
57
+ else
58
+ Template.interpolate("#{field_type}.range", start: start_val, end: end_val)
59
+ end
60
+ end
61
+
62
+ def single_template(val)
63
+ if field_type == :hour
64
+ format_hour_12(val)
65
+ else
66
+ plural = val == 1 ? '' : 's'
67
+ Template.interpolate("time.#{field_type}_single", value: val, value_plural: plural)
68
+ end
69
+ end
70
+
71
+ def format_value(val)
72
+ field_type == :hour ? format_hour_12(val) : val
73
+ end
74
+
75
+ def format_hour_12(hour_24)
76
+ hour_24 = hour_24.to_i % 24
77
+ period = hour_24 < 12 ? 'AM' : 'PM'
78
+ hour_12 = if hour_24.zero?
79
+ 12
80
+ elsif hour_24 > 12
81
+ hour_24 - 12
82
+ else
83
+ hour_24
84
+ end
85
+ "#{hour_12} #{period}"
86
+ end
87
+
88
+ def format_list(items)
89
+ return items.first if items.length == 1
90
+
91
+ if items.length == 2
92
+ items.join(' and ')
93
+ else
94
+ last = items.pop
95
+ "#{items.join(', ')}, and #{last}"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Describer
5
+ # Composes month descriptions
6
+ class MonthComposer
7
+ include AWSCronParser::Helpers
8
+
9
+ attr_reader :month, :is_aws
10
+
11
+ def initialize(config, is_aws: false)
12
+ @month = config[:month]
13
+ @is_aws = is_aws
14
+ end
15
+
16
+ def compose
17
+ return nil if wildcards?(month)
18
+
19
+ FieldDescribers::Month.new(month, is_aws: is_aws).describe
20
+ end
21
+
22
+ private
23
+
24
+ def wildcards?(*fields)
25
+ fields.all? { |f| f.nil? || f == '*' || f == '?' }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,183 @@
1
+ # CRON Expression Description Templates
2
+ # Usage: Templates can use variables like {variable_name} for interpolation
3
+ # Example: "Every {step} minutes" becomes "Every 5 minutes" when step=5
4
+
5
+ rate:
6
+ singular: "Every {unit}"
7
+ plural: "Every {value} {unit}s"
8
+
9
+ macro:
10
+ yearly: "Every year"
11
+ monthly: "Every month"
12
+ weekly: "Every week"
13
+ daily: "Every day"
14
+ hourly: "Every hour"
15
+
16
+ time:
17
+ at: "At {time}"
18
+ every_minute: "Every minute"
19
+ every_second: "Every second"
20
+ every_hour: "Every hour"
21
+ every_n_minutes: "Every {step} minute{step_plural}"
22
+ every_n_hours: "Every {step} hour{step_plural}"
23
+ every_n_seconds: "Every {step} second{step_plural}"
24
+
25
+ # Specific times
26
+ at_specific_time: "At {hour}:{minute} {period}"
27
+ at_specific_time_with_seconds: "At {hour}:{minute}:{second} {period}"
28
+ every_minute_at_second: "Every minute at second {second}"
29
+ every_hour_at_minute: "Every hour at minute {minute}"
30
+ every_n_minutes_starting_at: "Every {step} minute{step_plural} starting at {time}"
31
+
32
+ # Range patterns
33
+ minute_range: "from minute {start} to minute {end}"
34
+ hour_range: "from {start_hour} to {end_hour}"
35
+ second_range: "from second {start} to second {end}"
36
+
37
+ # Step patterns
38
+ minute_step_range: "every {step} minute{step_plural} starting at minute {start}"
39
+ hour_step_range: "every {step} hour{step_plural} starting at {start_hour}"
40
+ second_step_range: "every {step} second{step_plural} starting at second {start}"
41
+
42
+ # Single minute/hour/second
43
+ minute_single: "{value} minute{value_plural}"
44
+ hour_single: "{value} hour{value_plural}"
45
+ second_single: "{value} second{value_plural}"
46
+
47
+ # Lists
48
+ minute_list: "at minute{plural} {items}"
49
+ hour_list: "at {items}"
50
+ second_list: "at second{plural} {items}"
51
+
52
+ minute:
53
+ step: "Every {step} minute{step_plural}"
54
+ step_range: "Every {step} minute{step_plural} starting at minute {start}"
55
+ range: "Every hour from minute {start} to minute {end}"
56
+ list: "Every hour at minute{list_plural} {items}"
57
+
58
+ second:
59
+ step: "Every {step} second{step_plural}"
60
+ step_range: "Every {step} second{step_plural} starting at second {start}"
61
+ range: "Every minute from second {start} to second {end}"
62
+ list: "Every minute at second{list_plural} {items}"
63
+
64
+ hour:
65
+ step: "Every {step} hour{step_plural}"
66
+ step_range: "Every {step} hour{step_plural} starting at {start_hour}"
67
+ step_between: "Every {step} hour{step_plural} from {start_hour} to {end_hour}"
68
+ range: "Every day from {start_hour} to {end_hour}"
69
+ list: "at {items}"
70
+
71
+ day_of_month:
72
+ single: "on the {ordinal}"
73
+ range: "on the {start_ordinal} through {end_ordinal}"
74
+ step: "Every {step} day{step_plural}"
75
+ step_range: "Every {step} day{step_plural} starting on the {start_ordinal}"
76
+ list: "on the {items}"
77
+ last_day: "on the last day of the month"
78
+ closest_weekday: "on the closest weekday to the {ordinal}"
79
+
80
+ day_of_week:
81
+ single: "on {day_name}"
82
+ range: "{start_day} through {end_day}"
83
+ list: "on {items}"
84
+ nth_occurrence: "on the {nth_word} {day_name}"
85
+ last_occurrence: "on the last {day_name}"
86
+ step: "Every {step} day{step_plural} of the week"
87
+ step_from: "Every {step} day{step_plural} of the week starting on {start_day}"
88
+
89
+ month:
90
+ single: "in {month_name}"
91
+ range: "in {start_month} through {end_month}"
92
+ step_range: "Every {step} month{step_plural} starting in {start_month}"
93
+ list: "in {items}"
94
+ step: "Every {step} month{step_plural} starting in {start_month}"
95
+
96
+ # Complex compositions with multiple conditions
97
+ cron:
98
+ # Wildcard combinations
99
+ all_wildcards: "Every second"
100
+ time_only_wildcards: "Every minute"
101
+
102
+ # Day/DOW combinations
103
+ day_dow_both_specified: "At {time_part} on the {day_part} ({dow_part})"
104
+ day_dow_both_specified_month: "At {time_part} on the {day_part} ({dow_part}) in {month_desc}"
105
+ day_dow_time_day: "At {time_part} on the {day_part}"
106
+ day_dow_time_dow: "At {time_part} on {dow_part}"
107
+ day_dow_time_month: "At {time_part} in {month_desc}"
108
+ day_dow_day_dow: "On the {day_part} ({dow_part})"
109
+ day_dow_day_month: "On {month_desc} {day_part}"
110
+ day_dow_dow_month: "On {dow_part} in {month_desc}"
111
+
112
+ # Special day combinations
113
+ day_special_leap_year: "On February 29th at {time_clean} (leap years only)"
114
+ day_special_new_year: "On January 1st at {time_clean}"
115
+
116
+ # Time range patterns
117
+ time_range_second: "Every minute {time_desc}"
118
+ time_range_minute: "Every hour {time_desc}"
119
+ time_range_default: "Every day {time_desc}"
120
+
121
+ helpers:
122
+ # Ordinal word helpers
123
+ ordinal_words:
124
+ 1: "first"
125
+ 2: "second"
126
+ 3: "third"
127
+ 4: "fourth"
128
+ 5: "fifth"
129
+
130
+ # Pluralization
131
+ plural_suffix: "s"
132
+ no_plural: ""
133
+
134
+ # Composition templates for complex patterns
135
+ composition:
136
+ # Time + Day patterns
137
+ every_day: "Every day"
138
+ every_day_in_month: "Every day in {month_desc}"
139
+ every_day_at: "Every day at {time_clean}"
140
+ every_day_in_month_at_time: "Every day in {month_desc} at {time_clean}"
141
+ every_day_time_in_month: "Every day {time_desc} in {month_desc}"
142
+
143
+ # Specific day patterns
144
+ on_day_at_time: "On {day_plain}{month_suffix} at {time_clean}"
145
+ every_month_on_day: "On {day_desc} every month"
146
+ every_month_on_day_at_time: "On {day_plain} in {month_name} at {time_clean}"
147
+ every_year_on_month_day: "On {month_name} {day_plain} every year"
148
+ every_month_day_time: "{time_desc} on {month_name} {day_plain}"
149
+ time_on_day_of_every_month: "{time_desc} on {day_label} of every month"
150
+
151
+ # Last day patterns
152
+ last_day_of_month_at_time: "On the last day of {month_name} at {time_text}"
153
+
154
+ # Weekday patterns
155
+ nth_weekday: "On the {nth_word} {day_name}"
156
+ nth_weekday_at_time: "On the {nth_word} {day_name} at {time_clean}"
157
+ nth_weekday_in_month: "The {nth_word} {day_name} of {month_name} at {time_clean}"
158
+ nth_weekday_in_month_no_time: "On the {nth_word} {day_name} in {month_name}"
159
+ time_on_nth_weekday: "{time_desc} on the {nth_word} {day_name}"
160
+ time_on_nth_weekday_in_month: "{time_desc} on the {nth_word} {day_name} {month_desc}"
161
+
162
+ # Last weekday patterns
163
+ last_weekday: "On the last {day_name}"
164
+ last_weekday_at_time: "On the last {day_name} at {time_clean}"
165
+ last_weekday_in_month: "On the last {day_name} in {month_name}"
166
+ last_weekday_in_month_no_time: "On the last {day_name} in {month_name}"
167
+
168
+ # Every X minutes/hours patterns
169
+ every_minute_at_second: "Every minute at second {second}"
170
+ every_hour_at_minute: "Every hour at minute {minute}"
171
+ every_n_minutes_at: "Every {step} minute{step_plural} at {time_clean}"
172
+ every_n_hours_from: "Every {step} hour{step_plural} from {start_time} to {end_time}"
173
+
174
+ # Prepositions and connectors
175
+ prepositions:
176
+ at: "at"
177
+ on: "on"
178
+ in: "in"
179
+ from: "from"
180
+ to: "to"
181
+ through: "through"
182
+ and: "and"
183
+ or: "or"
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module AWSCronParser
6
+ module Describer
7
+ # Simple template interpolator for CRON descriptions
8
+ class Template
9
+ TEMPLATES = YAML.load_file(File.join(__dir__, 'template.en.yml')).freeze
10
+
11
+ class << self
12
+ # Main interpolation method with auto-pluralization
13
+ # Example: interpolate("time.every_n_minutes", step: 5)
14
+ # Returns: "Every 5 minutes"
15
+ def interpolate(template_path, **variables)
16
+ template = get(template_path)
17
+ return nil if template.nil?
18
+
19
+ render(template, auto_pluralize(template, variables))
20
+ end
21
+
22
+ # Get template by path (dot notation)
23
+ # Example: get("time.every_n_minutes") => "Every {step} minute{step_plural}"
24
+ def get(path)
25
+ path.to_s.split('.').reduce(TEMPLATES) { |hash, key| hash&.[](key) }
26
+ end
27
+
28
+ # Render template string with variables (faster, direct replacement)
29
+ # Example: render("Every {step} minutes", step: 5) => "Every 5 minutes"
30
+ def render(template, variables = {})
31
+ template.to_s.gsub(/\{(\w+)\}/) do
32
+ variables[::Regexp.last_match(1).to_sym] || variables[::Regexp.last_match(1)] ||
33
+ "{#{::Regexp.last_match(1)}}"
34
+ end
35
+ end
36
+
37
+ # Simple plural helper
38
+ def plural_for(count)
39
+ count == 1 ? '' : 's'
40
+ end
41
+
42
+ # Get ordinal word (first, second, third, etc.)
43
+ def ordinal_word(number)
44
+ ORDINAL_WORDS[number] || "#{number}th"
45
+ end
46
+
47
+ # Get all templates (for debugging)
48
+ def all
49
+ TEMPLATES
50
+ end
51
+
52
+ private
53
+
54
+ # Auto-calculate plural variables based on template and values
55
+ def auto_pluralize(template, variables)
56
+ processed = variables.dup
57
+
58
+ template.scan(/\{(\w+)\}/).flatten.uniq.each do |var_name|
59
+ next if processed.key?(var_name.to_sym) || processed.key?(var_name)
60
+
61
+ # Auto-pluralize: {step_plural} checks {step} value
62
+ if var_name.end_with?('_plural')
63
+ base_var = var_name.sub('_plural', '').to_sym
64
+ base_value = processed[base_var]
65
+ processed[var_name.to_sym] = plural_for(base_value) if base_value
66
+ # Generic plural: checks items count
67
+ elsif var_name == 'plural' && processed.key?(:items)
68
+ items_count = processed[:items].to_s.split(',').length
69
+ processed[:plural] = plural_for(items_count)
70
+ end
71
+ end
72
+
73
+ processed
74
+ end
75
+
76
+ # Ordinal words cache
77
+ ORDINAL_WORDS = {
78
+ 1 => 'first',
79
+ 2 => 'second',
80
+ 3 => 'third',
81
+ 4 => 'fourth',
82
+ 5 => 'fifth'
83
+ }.freeze
84
+ end
85
+ end
86
+ end
87
+ end