shared_tools 0.3.1 → 0.4.1
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/CHANGELOG.md +46 -16
- data/README.md +257 -262
- data/lib/shared_tools/browser_tool.rb +5 -0
- data/lib/shared_tools/calculator_tool.rb +4 -0
- data/lib/shared_tools/clipboard_tool.rb +4 -0
- data/lib/shared_tools/composite_analysis_tool.rb +4 -0
- data/lib/shared_tools/computer_tool.rb +5 -0
- data/lib/shared_tools/cron_tool.rb +4 -0
- data/lib/shared_tools/current_date_time_tool.rb +4 -0
- data/lib/shared_tools/data_science_kit.rb +4 -0
- data/lib/shared_tools/database.rb +4 -0
- data/lib/shared_tools/database_query_tool.rb +4 -0
- data/lib/shared_tools/database_tool.rb +5 -0
- data/lib/shared_tools/disk_tool.rb +5 -0
- data/lib/shared_tools/dns_tool.rb +4 -0
- data/lib/shared_tools/doc_tool.rb +5 -0
- data/lib/shared_tools/error_handling_tool.rb +4 -0
- data/lib/shared_tools/eval_tool.rb +5 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +37 -0
- data/lib/shared_tools/mcp/chart_client.rb +32 -0
- data/lib/shared_tools/mcp/github_client.rb +38 -0
- data/lib/shared_tools/mcp/hugging_face_client.rb +43 -0
- data/lib/shared_tools/mcp/memory_client.rb +33 -0
- data/lib/shared_tools/mcp/notion_client.rb +40 -0
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +33 -0
- data/lib/shared_tools/mcp/slack_client.rb +54 -0
- data/lib/shared_tools/mcp/streamable_http_patch.rb +42 -0
- data/lib/shared_tools/mcp/tavily_client.rb +41 -0
- data/lib/shared_tools/mcp.rb +45 -16
- data/lib/shared_tools/system_info_tool.rb +4 -0
- data/lib/shared_tools/tools/browser/base_tool.rb +8 -12
- data/lib/shared_tools/tools/browser/click_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/ferrum_driver.rb +119 -0
- data/lib/shared_tools/tools/browser/inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +19 -7
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +4 -2
- data/lib/shared_tools/tools/browser/visit_tool.rb +4 -2
- data/lib/shared_tools/tools/browser.rb +31 -2
- data/lib/shared_tools/tools/browser_tool.rb +6 -0
- data/lib/shared_tools/tools/clipboard_tool.rb +69 -144
- data/lib/shared_tools/tools/composite_analysis_tool.rb +60 -4
- data/lib/shared_tools/tools/computer/mac_driver.rb +37 -4
- data/lib/shared_tools/tools/cron_tool.rb +237 -379
- data/lib/shared_tools/tools/current_date_time_tool.rb +54 -120
- data/lib/shared_tools/tools/data_science_kit.rb +63 -13
- data/lib/shared_tools/tools/dns_tool.rb +335 -269
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +107 -0
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +171 -0
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +57 -0
- data/lib/shared_tools/tools/doc.rb +3 -0
- data/lib/shared_tools/tools/doc_tool.rb +101 -6
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
- data/lib/shared_tools/tools/enabler.rb +42 -0
- data/lib/shared_tools/tools/error_handling_tool.rb +3 -1
- data/lib/shared_tools/tools/notification/base_driver.rb +51 -0
- data/lib/shared_tools/tools/notification/linux_driver.rb +115 -0
- data/lib/shared_tools/tools/notification/mac_driver.rb +66 -0
- data/lib/shared_tools/tools/notification/null_driver.rb +29 -0
- data/lib/shared_tools/tools/notification.rb +12 -0
- data/lib/shared_tools/tools/notification_tool.rb +99 -0
- data/lib/shared_tools/tools/system_info_tool.rb +130 -343
- data/lib/shared_tools/tools/workflow_manager_tool.rb +32 -0
- data/lib/shared_tools/utilities.rb +193 -0
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools/weather_tool.rb +4 -0
- data/lib/shared_tools/workflow_manager_tool.rb +4 -0
- data/lib/shared_tools.rb +28 -38
- metadata +74 -9
- data/lib/shared_tools/mcp/github_mcp_server.rb +0 -58
- data/lib/shared_tools/mcp/imcp.rb +0 -28
- data/lib/shared_tools/mcp/tavily_mcp_server.rb +0 -44
- data/lib/shared_tools/tools/devops_toolkit.rb +0 -420
|
@@ -1,473 +1,331 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'ruby_llm/tool'
|
|
4
3
|
require 'time'
|
|
4
|
+
require_relative '../../shared_tools'
|
|
5
5
|
|
|
6
6
|
module SharedTools
|
|
7
7
|
module Tools
|
|
8
|
-
#
|
|
9
|
-
# Supports standard 5-field cron format (minute, hour, day of month, month, day of week).
|
|
8
|
+
# Parse, validate, explain, and generate cron expressions.
|
|
10
9
|
#
|
|
11
10
|
# @example
|
|
12
11
|
# tool = SharedTools::Tools::CronTool.new
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Parse, validate, and explain cron expressions.
|
|
20
|
-
|
|
21
|
-
Supports standard 5-field cron format:
|
|
22
|
-
- minute (0-59)
|
|
23
|
-
- hour (0-23)
|
|
24
|
-
- day of month (1-31)
|
|
25
|
-
- month (1-12 or JAN-DEC)
|
|
26
|
-
- day of week (0-7 or SUN-SAT, where 0 and 7 are Sunday)
|
|
27
|
-
|
|
28
|
-
Special characters supported:
|
|
29
|
-
- * (any value)
|
|
30
|
-
- , (value list separator)
|
|
31
|
-
- - (range of values)
|
|
32
|
-
- / (step values)
|
|
12
|
+
# tool.execute(action: 'parse', expression: '0 9 * * 1-5')
|
|
13
|
+
# tool.execute(action: 'validate', expression: '*/15 * * * *')
|
|
14
|
+
# tool.execute(action: 'next_times', expression: '0 * * * *', count: 5)
|
|
15
|
+
# tool.execute(action: 'generate', description: 'every day at 9am')
|
|
16
|
+
class CronTool < ::RubyLLM::Tool
|
|
17
|
+
def self.name = 'cron_tool'
|
|
33
18
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- 'validate': Check if a cron expression is valid
|
|
37
|
-
- 'next': Calculate the next N execution times
|
|
38
|
-
- 'generate': Generate a cron expression from a description
|
|
39
|
-
|
|
40
|
-
Example usage:
|
|
41
|
-
tool = SharedTools::Tools::CronTool.new
|
|
42
|
-
|
|
43
|
-
# Parse and explain
|
|
44
|
-
tool.execute(action: 'parse', expression: '0 9 * * 1-5')
|
|
45
|
-
# => "At 09:00, Monday through Friday"
|
|
19
|
+
description <<~DESC
|
|
20
|
+
Parse, validate, explain, and generate cron expressions (standard 5-field format).
|
|
46
21
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
22
|
+
Actions:
|
|
23
|
+
- 'parse' — Parse and explain a cron expression
|
|
24
|
+
- 'validate' — Check whether a cron expression is valid
|
|
25
|
+
- 'next_times' — List the next N execution times (default 5)
|
|
26
|
+
- 'generate' — Generate a cron expression from a natural language description
|
|
50
27
|
|
|
51
|
-
|
|
52
|
-
|
|
28
|
+
Cron format: minute hour day month weekday
|
|
29
|
+
- Each field accepts: number, range (1-5), list (1,3,5), step (*/15), or wildcard (*)
|
|
30
|
+
- Weekday: 0-7 (0 and 7 both mean Sunday)
|
|
53
31
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
32
|
+
Generate examples: 'every day at 9am', 'every monday at noon', 'every 15 minutes',
|
|
33
|
+
'every weekday', 'first day of every month at midnight'
|
|
34
|
+
DESC
|
|
57
35
|
|
|
58
36
|
params do
|
|
59
|
-
string
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
- 'next': Calculate next execution times
|
|
64
|
-
- 'generate': Generate expression from description
|
|
65
|
-
DESC
|
|
66
|
-
|
|
67
|
-
string :expression, description: <<~DESC.strip, required: false
|
|
68
|
-
The cron expression to parse, validate, or calculate.
|
|
69
|
-
Required for 'parse', 'validate', and 'next' actions.
|
|
70
|
-
DESC
|
|
71
|
-
|
|
72
|
-
string :description, description: <<~DESC.strip, required: false
|
|
73
|
-
Human-readable schedule description for 'generate' action.
|
|
74
|
-
Examples: 'every day at 9am', 'every monday at noon', 'every 5 minutes'
|
|
75
|
-
DESC
|
|
76
|
-
|
|
77
|
-
integer :count, description: <<~DESC.strip, required: false
|
|
78
|
-
Number of next execution times to return for 'next' action.
|
|
79
|
-
Default: 5, Maximum: 20
|
|
80
|
-
DESC
|
|
37
|
+
string :action, description: "Action: 'parse', 'validate', 'next_times', 'generate'"
|
|
38
|
+
string :expression, required: false, description: "5-field cron expression. Required for parse, validate, next_times."
|
|
39
|
+
integer :count, required: false, description: "Number of next execution times. Default: 5."
|
|
40
|
+
string :description, required: false, description: "Natural language schedule description. Required for generate."
|
|
81
41
|
end
|
|
82
42
|
|
|
83
|
-
DAYS_OF_WEEK = {
|
|
84
|
-
'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3,
|
|
85
|
-
'THU' => 4, 'FRI' => 5, 'SAT' => 6
|
|
86
|
-
}.freeze
|
|
87
|
-
|
|
88
|
-
MONTHS = {
|
|
89
|
-
'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4,
|
|
90
|
-
'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8,
|
|
91
|
-
'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12
|
|
92
|
-
}.freeze
|
|
93
|
-
|
|
94
43
|
# @param logger [Logger] optional logger
|
|
95
44
|
def initialize(logger: nil)
|
|
96
45
|
@logger = logger || RubyLLM.logger
|
|
97
46
|
end
|
|
98
47
|
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# @param
|
|
102
|
-
# @param
|
|
103
|
-
# @param description [String, nil] schedule description for generate
|
|
104
|
-
# @param count [Integer, nil] number of next executions
|
|
48
|
+
# @param action [String] action to perform
|
|
49
|
+
# @param expression [String, nil] cron expression
|
|
50
|
+
# @param count [Integer, nil] number of times for next_times
|
|
51
|
+
# @param description [String, nil] natural language description for generate
|
|
105
52
|
# @return [Hash] result
|
|
106
|
-
def execute(action:, expression: nil,
|
|
107
|
-
@logger.info("CronTool#execute action=#{action
|
|
53
|
+
def execute(action:, expression: nil, count: nil, description: nil)
|
|
54
|
+
@logger.info("CronTool#execute action=#{action}")
|
|
108
55
|
|
|
109
56
|
case action.to_s.downcase
|
|
110
|
-
when 'parse'
|
|
111
|
-
|
|
112
|
-
when '
|
|
113
|
-
|
|
114
|
-
when 'next'
|
|
115
|
-
next_executions(expression, count || 5)
|
|
116
|
-
when 'generate'
|
|
117
|
-
generate_expression(description)
|
|
57
|
+
when 'parse' then parse_expression(expression)
|
|
58
|
+
when 'validate' then validate_expression(expression)
|
|
59
|
+
when 'next_times' then next_times(expression, (count || 5).to_i)
|
|
60
|
+
when 'generate' then generate_expression(description)
|
|
118
61
|
else
|
|
119
|
-
{
|
|
120
|
-
success: false,
|
|
121
|
-
error: "Unknown action: #{action}. Valid actions are: parse, validate, next, generate"
|
|
122
|
-
}
|
|
62
|
+
{ success: false, error: "Unknown action '#{action}'. Use: parse, validate, next_times, generate" }
|
|
123
63
|
end
|
|
124
64
|
rescue => e
|
|
125
65
|
@logger.error("CronTool error: #{e.message}")
|
|
126
|
-
{
|
|
127
|
-
success: false,
|
|
128
|
-
error: e.message
|
|
129
|
-
}
|
|
66
|
+
{ success: false, error: e.message }
|
|
130
67
|
end
|
|
131
68
|
|
|
132
69
|
private
|
|
133
70
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
}
|
|
71
|
+
FIELD_NAMES = %w[minute hour day month weekday].freeze
|
|
72
|
+
FIELD_RANGES = {
|
|
73
|
+
'minute' => 0..59,
|
|
74
|
+
'hour' => 0..23,
|
|
75
|
+
'day' => 1..31,
|
|
76
|
+
'month' => 1..12,
|
|
77
|
+
'weekday' => 0..7
|
|
78
|
+
}.freeze
|
|
79
|
+
DAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
|
|
80
|
+
MONTH_NAMES = %w[January February March April May June July August
|
|
81
|
+
September October November December].freeze
|
|
82
|
+
|
|
83
|
+
# -------------------------------------------------------------------------
|
|
84
|
+
# Action implementations
|
|
85
|
+
# -------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def parse_expression(expr)
|
|
88
|
+
require_expr!(expr)
|
|
89
|
+
parts = split_expr(expr)
|
|
90
|
+
fields = {}
|
|
91
|
+
FIELD_NAMES.zip(parts).each do |name, raw|
|
|
92
|
+
fields[name] = { raw: raw, values: expand_field(raw, FIELD_RANGES[name]) }
|
|
93
|
+
end
|
|
94
|
+
{ success: true, valid: true, expression: expr, fields: fields, explanation: explain(parts) }
|
|
95
|
+
rescue ArgumentError => e
|
|
96
|
+
{ success: false, valid: false, expression: expr, error: e.message }
|
|
162
97
|
end
|
|
163
98
|
|
|
164
|
-
def validate_expression(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
parts
|
|
99
|
+
def validate_expression(expr)
|
|
100
|
+
require_expr!(expr)
|
|
101
|
+
parts = split_expr(expr)
|
|
102
|
+
FIELD_NAMES.zip(parts).each { |name, raw| expand_field(raw, FIELD_RANGES[name]) }
|
|
103
|
+
{ success: true, valid: true, expression: expr, explanation: explain(parts) }
|
|
104
|
+
rescue ArgumentError => e
|
|
105
|
+
{ success: true, valid: false, expression: expr, error: e.message }
|
|
106
|
+
end
|
|
168
107
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
108
|
+
def next_times(expr, count)
|
|
109
|
+
require_expr!(expr)
|
|
110
|
+
parts = split_expr(expr)
|
|
111
|
+
|
|
112
|
+
sets = FIELD_NAMES.map.with_index { |name, i| expand_field(parts[i], FIELD_RANGES[name]) }
|
|
113
|
+
mins_set, hrs_set, days_set, months_set, wdays_set = sets
|
|
114
|
+
|
|
115
|
+
# Normalise Sunday: weekday 7 == 0
|
|
116
|
+
wdays_set = wdays_set.map { |d| d == 7 ? 0 : d }.uniq.sort
|
|
117
|
+
|
|
118
|
+
times = []
|
|
119
|
+
t = Time.now
|
|
120
|
+
# Advance to the next minute boundary
|
|
121
|
+
t = Time.new(t.year, t.month, t.day, t.hour, t.min + 1, 0)
|
|
122
|
+
limit = 527_040 # 1 year of minutes — safety cap
|
|
123
|
+
|
|
124
|
+
while times.size < count && limit > 0
|
|
125
|
+
limit -= 1
|
|
126
|
+
if months_set.include?(t.month) &&
|
|
127
|
+
days_set.include?(t.day) &&
|
|
128
|
+
wdays_set.include?(t.wday) &&
|
|
129
|
+
hrs_set.include?(t.hour) &&
|
|
130
|
+
mins_set.include?(t.min)
|
|
131
|
+
times << t.strftime('%Y-%m-%d %H:%M (%A)')
|
|
132
|
+
end
|
|
133
|
+
t += 60
|
|
174
134
|
end
|
|
175
135
|
|
|
176
|
-
|
|
136
|
+
{ success: true, expression: expr, explanation: explain(parts), next_times: times }
|
|
137
|
+
end
|
|
177
138
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
errors << validate_field(hour, 0, 23, 'hour')
|
|
181
|
-
errors << validate_field(dom, 1, 31, 'day of month')
|
|
182
|
-
errors << validate_field(month, 1, 12, 'month')
|
|
183
|
-
errors << validate_field(dow, 0, 7, 'day of week')
|
|
139
|
+
def generate_expression(desc)
|
|
140
|
+
raise ArgumentError, "description is required for the generate action" if desc.nil? || desc.strip.empty?
|
|
184
141
|
|
|
185
|
-
|
|
142
|
+
d = desc.downcase
|
|
143
|
+
expr = match_pattern(d)
|
|
186
144
|
|
|
187
|
-
if
|
|
188
|
-
|
|
145
|
+
if expr
|
|
146
|
+
parts = expr.split
|
|
147
|
+
{ success: true, description: desc, expression: expr, explanation: explain(parts) }
|
|
189
148
|
else
|
|
190
|
-
{
|
|
149
|
+
{
|
|
150
|
+
success: false,
|
|
151
|
+
description: desc,
|
|
152
|
+
error: "Could not generate an expression from that description. " \
|
|
153
|
+
"Try: 'every day at 9am', 'every monday at noon', 'every 15 minutes', " \
|
|
154
|
+
"'every weekday', 'first day of every month at midnight'."
|
|
155
|
+
}
|
|
191
156
|
end
|
|
192
157
|
end
|
|
193
158
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
validation = validate_expression(expression)
|
|
198
|
-
return { success: false, error: validation[:errors]&.join(', ') || validation[:error] } unless validation[:valid]
|
|
199
|
-
|
|
200
|
-
count = [[count.to_i, 1].max, 20].min
|
|
201
|
-
|
|
202
|
-
parts = expression.strip.split(/\s+/)
|
|
203
|
-
minute_spec, hour_spec, dom_spec, month_spec, dow_spec = parts
|
|
204
|
-
|
|
205
|
-
minutes = expand_field(minute_spec, 0, 59)
|
|
206
|
-
hours = expand_field(hour_spec, 0, 23)
|
|
207
|
-
doms = expand_field(dom_spec, 1, 31)
|
|
208
|
-
months = expand_field(month_spec, 1, 12)
|
|
209
|
-
dows = expand_field(dow_spec, 0, 6)
|
|
210
|
-
|
|
211
|
-
executions = []
|
|
212
|
-
current = Time.now + 60 # Start from next minute
|
|
159
|
+
# -------------------------------------------------------------------------
|
|
160
|
+
# Pattern matching for generate
|
|
161
|
+
# -------------------------------------------------------------------------
|
|
213
162
|
|
|
214
|
-
|
|
215
|
-
|
|
163
|
+
def match_pattern(d)
|
|
164
|
+
return '* * * * *' if d.include?('every minute')
|
|
216
165
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if matches_cron?(current, minutes, hours, doms, months, dows)
|
|
221
|
-
executions << current.strftime('%Y-%m-%d %H:%M')
|
|
222
|
-
current += 60
|
|
223
|
-
else
|
|
224
|
-
current += 60
|
|
225
|
-
end
|
|
166
|
+
if (m = d.match(/every\s+(\d+)\s+minutes?/))
|
|
167
|
+
return "*/#{m[1]} * * * *"
|
|
226
168
|
end
|
|
227
169
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
expression: expression,
|
|
231
|
-
count: executions.length,
|
|
232
|
-
next_executions: executions
|
|
233
|
-
}
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def generate_expression(description)
|
|
237
|
-
return { success: false, error: "Description is required" } if description.nil? || description.empty?
|
|
238
|
-
|
|
239
|
-
desc = description.downcase.strip
|
|
240
|
-
expression = nil
|
|
241
|
-
|
|
242
|
-
# Common patterns - more specific patterns must come before general ones
|
|
243
|
-
case desc
|
|
244
|
-
when /every\s+minute/
|
|
245
|
-
expression = '* * * * *'
|
|
246
|
-
when /every\s+(\d+)\s+minutes?/
|
|
247
|
-
interval = $1.to_i
|
|
248
|
-
expression = "*/#{interval} * * * *"
|
|
249
|
-
when /every\s+hour/
|
|
250
|
-
expression = '0 * * * *'
|
|
251
|
-
when /every\s+(\d+)\s+hours?/
|
|
252
|
-
interval = $1.to_i
|
|
253
|
-
expression = "0 */#{interval} * * *"
|
|
254
|
-
when /every\s+day\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
|
|
255
|
-
hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
|
|
256
|
-
hour += 12 if ampm == 'pm' && hour != 12
|
|
257
|
-
hour = 0 if ampm == 'am' && hour == 12
|
|
258
|
-
expression = "#{min} #{hour} * * *"
|
|
259
|
-
when /every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
|
|
260
|
-
day_name = $1.upcase[0..2]
|
|
261
|
-
day_num = DAYS_OF_WEEK[day_name]
|
|
262
|
-
hour, min, ampm = $2.to_i, ($3 || '0').to_i, $4
|
|
263
|
-
hour += 12 if ampm == 'pm' && hour != 12
|
|
264
|
-
hour = 0 if ampm == 'am' && hour == 12
|
|
265
|
-
expression = "#{min} #{hour} * * #{day_num}"
|
|
266
|
-
when /every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/
|
|
267
|
-
day_name = $1.upcase[0..2]
|
|
268
|
-
day_num = DAYS_OF_WEEK[day_name]
|
|
269
|
-
expression = "0 0 * * #{day_num}"
|
|
270
|
-
when /weekdays?\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
|
|
271
|
-
hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
|
|
272
|
-
hour += 12 if ampm == 'pm' && hour != 12
|
|
273
|
-
hour = 0 if ampm == 'am' && hour == 12
|
|
274
|
-
expression = "#{min} #{hour} * * 1-5"
|
|
275
|
-
when /weekends?\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
|
|
276
|
-
hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
|
|
277
|
-
hour += 12 if ampm == 'pm' && hour != 12
|
|
278
|
-
hour = 0 if ampm == 'am' && hour == 12
|
|
279
|
-
expression = "#{min} #{hour} * * 0,6"
|
|
280
|
-
when /at\s+noon/
|
|
281
|
-
expression = '0 12 * * *'
|
|
282
|
-
when /at\s+midnight/
|
|
283
|
-
expression = '0 0 * * *'
|
|
284
|
-
when /monthly|first\s+of\s+(the\s+)?month/
|
|
285
|
-
expression = '0 0 1 * *'
|
|
286
|
-
when /yearly|annually/
|
|
287
|
-
expression = '0 0 1 1 *'
|
|
288
|
-
when /hourly\s+at\s+:?(\d{2})/
|
|
289
|
-
min = $1.to_i
|
|
290
|
-
expression = "#{min} * * * *"
|
|
170
|
+
if d.match?(/every\s+hour\b/) && !d.match?(/\d+\s+hours?/)
|
|
171
|
+
return '0 * * * *'
|
|
291
172
|
end
|
|
292
173
|
|
|
293
|
-
if
|
|
294
|
-
|
|
295
|
-
{
|
|
296
|
-
success: true,
|
|
297
|
-
description: description,
|
|
298
|
-
expression: expression,
|
|
299
|
-
explanation: parsed[:description]
|
|
300
|
-
}
|
|
301
|
-
else
|
|
302
|
-
{
|
|
303
|
-
success: false,
|
|
304
|
-
error: "Could not parse description: '#{description}'. Try formats like 'every day at 9am', 'every monday at noon', 'every 5 minutes'"
|
|
305
|
-
}
|
|
174
|
+
if (m = d.match(/every\s+(\d+)\s+hours?/))
|
|
175
|
+
return "0 */#{m[1]} * * *"
|
|
306
176
|
end
|
|
307
|
-
end
|
|
308
177
|
|
|
309
|
-
|
|
310
|
-
return
|
|
178
|
+
return '0 9 * * 1-5' if d.include?('weekday')
|
|
179
|
+
return '0 0 * * 0,6' if d.include?('weekend')
|
|
311
180
|
|
|
312
|
-
|
|
313
|
-
if field.include?('/')
|
|
314
|
-
base, step = field.split('/')
|
|
315
|
-
return "Invalid step value in #{name}" unless step =~ /^\d+$/ && step.to_i > 0
|
|
316
|
-
return validate_field(base, min, max, name) unless base == '*'
|
|
317
|
-
return nil
|
|
318
|
-
end
|
|
181
|
+
day_pattern = DAY_NAMES.map(&:downcase).join('|')
|
|
319
182
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
return "Invalid range in #{name}" unless parts.length == 2
|
|
324
|
-
start_val = normalize_value(parts[0], name)
|
|
325
|
-
end_val = normalize_value(parts[1], name)
|
|
326
|
-
return "Invalid range values in #{name}" if start_val.nil? || end_val.nil?
|
|
327
|
-
return "Range out of bounds in #{name}" if start_val < min || end_val > max || start_val > end_val
|
|
328
|
-
return nil
|
|
183
|
+
if (m = d.match(/every\s+(#{day_pattern})\s+at\s+(\d+)(?::(\d+))?\s*(am|pm)?/))
|
|
184
|
+
day_num = DAY_NAMES.map(&:downcase).index(m[1])
|
|
185
|
+
return "#{hm_to_cron(m[2], m[3], m[4])} * * #{day_num}"
|
|
329
186
|
end
|
|
330
187
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
err = validate_field(val.strip, min, max, name)
|
|
335
|
-
return err if err
|
|
336
|
-
end
|
|
337
|
-
return nil
|
|
188
|
+
if (m = d.match(/every\s+(#{day_pattern})\s+at\s+noon/))
|
|
189
|
+
day_num = DAY_NAMES.map(&:downcase).index(m[1])
|
|
190
|
+
return "0 12 * * #{day_num}"
|
|
338
191
|
end
|
|
339
192
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
nil
|
|
346
|
-
end
|
|
193
|
+
if (m = d.match(/every\s+(#{day_pattern})/))
|
|
194
|
+
day_num = DAY_NAMES.map(&:downcase).index(m[1])
|
|
195
|
+
return "0 0 * * #{day_num}"
|
|
196
|
+
end
|
|
347
197
|
|
|
348
|
-
|
|
349
|
-
return
|
|
198
|
+
return '0 12 * * *' if d.include?('noon')
|
|
199
|
+
return '0 0 * * *' if d.include?('midnight')
|
|
350
200
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return DAYS_OF_WEEK[val.upcase] if DAYS_OF_WEEK.key?(val.upcase)
|
|
201
|
+
if (m = d.match(/every\s+day\s+at\s+(\d+)(?::(\d+))?\s*(am|pm)?/))
|
|
202
|
+
return "#{hm_to_cron(m[1], m[2], m[3])} * * *"
|
|
354
203
|
end
|
|
355
204
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return MONTHS[val.upcase] if MONTHS.key?(val.upcase)
|
|
205
|
+
if (m = d.match(/first\s+day\s+(?:of\s+(?:every\s+)?month\s+)?at\s+(\d+)(?::(\d+))?\s*(am|pm)?/))
|
|
206
|
+
return "#{hm_to_cron(m[1], m[2], m[3])} 1 * *"
|
|
359
207
|
end
|
|
360
208
|
|
|
361
|
-
return
|
|
209
|
+
return '0 0 1 * *' if d.match?(/first\s+day/)
|
|
362
210
|
|
|
363
211
|
nil
|
|
364
212
|
end
|
|
365
213
|
|
|
366
|
-
|
|
367
|
-
|
|
214
|
+
# Convert hour/minute/ampm strings to "min hour" cron tokens.
|
|
215
|
+
def hm_to_cron(h, m, ap)
|
|
216
|
+
hour = h.to_i
|
|
217
|
+
min = (m || '0').to_i
|
|
218
|
+
hour += 12 if ap == 'pm' && hour != 12
|
|
219
|
+
hour = 0 if ap == 'am' && hour == 12
|
|
220
|
+
"#{min} #{hour}"
|
|
221
|
+
end
|
|
368
222
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
223
|
+
# -------------------------------------------------------------------------
|
|
224
|
+
# Field expansion
|
|
225
|
+
# -------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
# Expand one cron field to a sorted array of integers.
|
|
228
|
+
def expand_field(value, range)
|
|
229
|
+
return range.to_a if value == '*'
|
|
230
|
+
|
|
231
|
+
result = []
|
|
232
|
+
value.split(',').each do |part|
|
|
233
|
+
if part.include?('/')
|
|
234
|
+
base_str, step_str = part.split('/')
|
|
235
|
+
step = step_str.to_i
|
|
236
|
+
raise ArgumentError, "Step must be >= 1, got #{step}" if step < 1
|
|
237
|
+
base_set = base_str == '*' ? range.to_a : expand_range_part(base_str, range)
|
|
238
|
+
result.concat(base_set.each_with_index.filter_map { |v, i| v if (i % step).zero? })
|
|
239
|
+
elsif part.include?('-')
|
|
240
|
+
a, b = part.split('-').map(&:to_i)
|
|
241
|
+
unless range_with_sunday(range).cover?(a) && range_with_sunday(range).cover?(b)
|
|
242
|
+
raise ArgumentError, "Range #{part} is out of bounds for #{range}"
|
|
243
|
+
end
|
|
244
|
+
result.concat((a..b).to_a)
|
|
245
|
+
else
|
|
246
|
+
v = part.to_i
|
|
247
|
+
unless range_with_sunday(range).cover?(v)
|
|
248
|
+
raise ArgumentError, "Value #{v} is out of range #{range}"
|
|
249
|
+
end
|
|
250
|
+
result << v
|
|
251
|
+
end
|
|
374
252
|
end
|
|
375
253
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
end
|
|
254
|
+
result.uniq.sort
|
|
255
|
+
end
|
|
379
256
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
257
|
+
def expand_range_part(str, range)
|
|
258
|
+
if str.include?('-')
|
|
259
|
+
a, b = str.split('-').map(&:to_i)
|
|
260
|
+
(a..b).to_a
|
|
261
|
+
else
|
|
262
|
+
v = str.to_i
|
|
263
|
+
(v..range.last).to_a
|
|
383
264
|
end
|
|
384
|
-
|
|
385
|
-
[normalize_value(field, '') || field.to_i]
|
|
386
265
|
end
|
|
387
266
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return false unless months.include?(time.month)
|
|
392
|
-
|
|
393
|
-
# Day matching: either day of month OR day of week must match
|
|
394
|
-
# (unless both are specified as specific values)
|
|
395
|
-
dom_match = doms.include?(time.day)
|
|
396
|
-
dow_match = dows.include?(time.wday)
|
|
397
|
-
|
|
398
|
-
dom_match || dow_match
|
|
267
|
+
# Allow weekday 7 (Sunday alias)
|
|
268
|
+
def range_with_sunday(range)
|
|
269
|
+
range == FIELD_RANGES['weekday'] ? (0..7) : range
|
|
399
270
|
end
|
|
400
271
|
|
|
401
|
-
|
|
402
|
-
|
|
272
|
+
# -------------------------------------------------------------------------
|
|
273
|
+
# Human-readable explanation
|
|
274
|
+
# -------------------------------------------------------------------------
|
|
403
275
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
276
|
+
def explain(parts)
|
|
277
|
+
min, hour, day, month, weekday = parts
|
|
278
|
+
segments = []
|
|
407
279
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
280
|
+
segments << if min == '*' then 'every minute'
|
|
281
|
+
elsif min.start_with?('*/') then "every #{min[2..]} minutes"
|
|
282
|
+
else "at minute #{min}"
|
|
283
|
+
end
|
|
412
284
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
end
|
|
285
|
+
segments << if hour == '*' then 'of every hour'
|
|
286
|
+
elsif hour.start_with?('*/') then "every #{hour[2..]} hours"
|
|
287
|
+
else "at #{fmt_hour(hour)}"
|
|
288
|
+
end
|
|
418
289
|
|
|
419
|
-
#
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if day_names == %w[Monday Tuesday Wednesday Thursday Friday]
|
|
423
|
-
parts << "on weekdays"
|
|
424
|
-
elsif day_names == %w[Sunday Saturday]
|
|
425
|
-
parts << "on weekends"
|
|
426
|
-
else
|
|
427
|
-
parts << "on #{day_names.join(', ')}"
|
|
428
|
-
end
|
|
429
|
-
end
|
|
290
|
+
segments << "on day #{day} of the month" unless day == '*'
|
|
291
|
+
segments << "in #{fmt_month(month)}" unless month == '*'
|
|
292
|
+
segments << "on #{fmt_weekday(weekday)}" unless weekday == '*'
|
|
430
293
|
|
|
431
|
-
|
|
294
|
+
segments.join(', ')
|
|
432
295
|
end
|
|
433
296
|
|
|
434
|
-
def
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
return "Every minute during hour(s) #{hours.join(', ')}"
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
if hour == '*'
|
|
445
|
-
minutes = expand_field(minute, 0, 59)
|
|
446
|
-
if minute.include?('/')
|
|
447
|
-
return "Every #{minute.split('/').last} minutes"
|
|
448
|
-
end
|
|
449
|
-
return "At minute #{minutes.join(', ')} of every hour"
|
|
450
|
-
end
|
|
451
|
-
|
|
452
|
-
hours = expand_field(hour, 0, 23)
|
|
453
|
-
minutes = expand_field(minute, 0, 59)
|
|
297
|
+
def fmt_hour(h)
|
|
298
|
+
return h if h.match?(/[,\-\/]/)
|
|
299
|
+
n = h.to_i
|
|
300
|
+
disp = n == 0 ? 12 : (n > 12 ? n - 12 : n)
|
|
301
|
+
ampm = n < 12 ? 'AM' : 'PM'
|
|
302
|
+
"#{disp}:00 #{ampm}"
|
|
303
|
+
end
|
|
454
304
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
305
|
+
def fmt_month(m)
|
|
306
|
+
return m if m.match?(/[,\-\/]/)
|
|
307
|
+
MONTH_NAMES[m.to_i - 1] || m
|
|
308
|
+
end
|
|
458
309
|
|
|
459
|
-
|
|
310
|
+
def fmt_weekday(w)
|
|
311
|
+
return 'weekdays (Mon–Fri)' if w == '1-5'
|
|
312
|
+
return w if w.match?(/[,\/]/)
|
|
313
|
+
return w if w.include?('-')
|
|
314
|
+
DAY_NAMES[w.to_i % 7] || w
|
|
460
315
|
end
|
|
461
316
|
|
|
462
|
-
|
|
463
|
-
|
|
317
|
+
# -------------------------------------------------------------------------
|
|
318
|
+
# Helpers
|
|
319
|
+
# -------------------------------------------------------------------------
|
|
464
320
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
end
|
|
321
|
+
def require_expr!(expr)
|
|
322
|
+
raise ArgumentError, "expression is required for this action" if expr.nil? || expr.strip.empty?
|
|
323
|
+
end
|
|
469
324
|
|
|
470
|
-
|
|
325
|
+
def split_expr(expr)
|
|
326
|
+
parts = expr.strip.split(/\s+/)
|
|
327
|
+
raise ArgumentError, "Cron expression must have 5 fields, got #{parts.size}" unless parts.size == 5
|
|
328
|
+
parts
|
|
471
329
|
end
|
|
472
330
|
end
|
|
473
331
|
end
|