1 package com.atlassian.core.cron.parser;
2
3 import com.atlassian.core.cron.CronEditorBean;
4 import org.apache.commons.lang.StringUtils;
5
6 import java.util.StringTokenizer;
7
8 /**
9 * Represents a cron string with accessor methods to get at the individual fields. This is only used to back our
10 * Cron editor. This will tell you via the {@link #isValidForEditor()} method whether the cron string this is constructed
11 * with will be parseable via the editor. To populate the editor use the {@link #getCronEditorBean()} method.
12 * <p/>
13 * There are four modes that the editor supports:
14 * <ol>
15 * <li>Daily Mode</li>
16 * <li>Days Per Week Mode</li>
17 * <li>Days Per Month Mode</li>
18 * <li>Advanced Mode</li>
19 * </ol>
20 * <p/>
21 * If a cron string is not valid for the editor then the only available mode will be the advanced mode and the editor
22 * state methods (e.g. {@link #getDayOfMonth()} , {@link #getHoursEntry()} ) will return details of the default state
23 * represented by {@link #DEFAULT_CRONSTRING} as they are not able to represent the advanced cron string.
24 * <p/>
25 * The validation that this object performs is in the context of valid cron strings that will fit into the editor. This
26 * object does not validate that the over all string is a valid cron string. This should be accomplished by validating
27 * the string against a {@link org.quartz.CronTrigger}.
28 */
29 public class CronExpressionParser
30 {
31 /**
32 * Cron string that puts the editor into the default state.
33 */
34 public static final String DEFAULT_CRONSTRING = "0 0 1 ? * *";
35
36 private static final String VALID_DAY_OF_MONTH = "0123456789L";
37 private static final String WILDCARD = "*";
38 private static final String NOT_APPLICABLE = "?";
39 private static final int MINUTES_IN_HOUR = 60;
40 private static final int NUM_CRON_FIELDS = 7;
41 private static final int NUM_CRON_FIELDS_NO_YEAR = NUM_CRON_FIELDS - 1;
42
43 private CronMinutesEntry minutesEntry;
44 private CronHoursEntry hoursEntry;
45 private String dayOfMonth;
46 private String month;
47 private String daysOfWeek;
48 private CronDayOfWeekEntry daysOfWeekEntry;
49 private String year;
50 private String cronString;
51 private boolean isDaily;
52 private boolean isDayPerWeek;
53 private boolean isDaysPerMonth;
54 private boolean isAdvanced;
55 private boolean validForEditor;
56 private String seconds; // only used for advanced mode
57
58
59 /**
60 * Creates a parser in default state using {@link #DEFAULT_CRONSTRING}.
61 */
62 public CronExpressionParser()
63 {
64 this(DEFAULT_CRONSTRING);
65 }
66
67 /**
68 * Parses the given cronString to establish the state of this CronExpressionParser.
69 *
70 * @param cronString the cron string to parse.
71 */
72 public CronExpressionParser(String cronString)
73 {
74 this.cronString = cronString;
75 parseAndValidateCronString(this.cronString);
76 }
77
78 /**
79 * Will provide the {@link com.atlassian.core.cron.CronEditorBean} which represents the state of the
80 * form for the configured cron string.
81 *
82 * @return the bean
83 */
84 public CronEditorBean getCronEditorBean()
85 {
86 CronEditorBean cronEditorBean = new CronEditorBean();
87 cronEditorBean.setCronString(cronString);
88
89 cronEditorBean.setSeconds(seconds);
90
91 cronEditorBean.setDayOfMonth(dayOfMonth);
92 cronEditorBean.setIncrementInMinutes(Integer.toString(getIncrementInMinutes()));
93
94 // Either set the time as runOnce or the from/to
95 if (getIncrementInMinutes() == 0)
96 {
97 cronEditorBean.setHoursRunOnce(Integer.toString(getHoursEntry().getRunOnce()));
98 cronEditorBean.setHoursRunOnceMeridian(getHoursEntry().getRunOnceMeridian());
99 }
100 else
101 {
102 // range
103 cronEditorBean.setHoursFrom(Integer.toString(getHoursEntry().getFrom()));
104 cronEditorBean.setHoursFromMeridian(getHoursEntry().getFromMeridian());
105 cronEditorBean.setHoursTo(Integer.toString(getHoursEntry().getTo()));
106 cronEditorBean.setHoursToMeridian(getHoursEntry().getToMeridian());
107 }
108
109 // Set the minute values
110 cronEditorBean.setMinutes(Integer.toString(getMinutesEntry().getRunOnce()));
111
112 // Set the day of week values + the ordinal
113 cronEditorBean.setSpecifiedDaysOfWeek(getDaysOfWeekEntry().getDaysAsNumbers());
114 cronEditorBean.setDayInMonthOrdinal(getDaysOfWeekEntry().getDayInMonthOrdinal());
115
116 // Lastly lets set the mode
117 if (isDailyMode())
118 {
119 cronEditorBean.setMode(CronEditorBean.DAILY_SPEC_MODE);
120 }
121 else if (isDayPerWeekMode())
122 {
123 cronEditorBean.setMode(CronEditorBean.DAYS_OF_WEEK_SPEC_MODE);
124 }
125 else if (isDaysPerMonthMode())
126 {
127 // Set the sub-mode radio select for this mode
128 cronEditorBean.setDayOfWeekOfMonth(isDayOfWeekOfMonth());
129 cronEditorBean.setMode(CronEditorBean.DAYS_OF_MONTH_SPEC_MODE);
130 }
131 else
132 {
133 cronEditorBean.setMode(CronEditorBean.ADVANCED_MODE);
134 }
135
136 return cronEditorBean;
137 }
138
139 /**
140 * Returns the cron string that the object was constructed with. This method does not guarantee that the returned
141 * cron string is valid according the the {@link org.quartz.CronTrigger}.
142 *
143 * @return unmodified cronString passed into the constructor
144 */
145 public String getCronString()
146 {
147 return cronString;
148 }
149
150 /**
151 * Returns true only if the cron string can be handled by the cron editor UI.
152 * If this method returns false then all method but {@link #getCronString()} will throw an IllegalStateException
153 * work properly.
154 *
155 * @return true only if the editor has a state that corresponds to this cron expression.
156 */
157 public boolean isValidForEditor()
158 {
159 return validForEditor;
160 }
161
162 /**
163 * Will return true if the passed in cron string is not valid for the editor.
164 *
165 * @return true if the cron string can not be handled, false otherwise.
166 */
167 public boolean isAdvancedMode()
168 {
169 return isAdvanced;
170 }
171
172 /**
173 * Will return true if the editors daily mode can handle the provided cron string.
174 *
175 * @return true only if the mode is daily.
176 */
177 public boolean isDailyMode()
178 {
179 return isDaily;
180 }
181
182 /**
183 * Will return true if the editors day per week mode can handle the provided cron string.
184 *
185 * @return true only if we are in day per week mode.
186 */
187 public boolean isDayPerWeekMode()
188 {
189 return isDayPerWeek;
190 }
191
192 /**
193 * Will return true if the editors days per month mode can handle the provided cron string.
194 *
195 * @return true only if we are in days per month mode.
196 */
197 public boolean isDaysPerMonthMode()
198 {
199 return isDaysPerMonth;
200 }
201
202 /**
203 * Returns true if {@link #isDaysPerMonthMode()} is true and the string in the days of week field can be handled
204 * by the editor.
205 *
206 * @return true only if we are in the Nth Xday of the month mode (where N is a week in month number and X is mon,tue etc)
207 */
208 public boolean isDayOfWeekOfMonth()
209 {
210 return notApplicable(dayOfMonth) && !isWild(daysOfWeek) && !notApplicable(daysOfWeek);
211 }
212
213 /**
214 * Gets the day of month field specified in the cron string.
215 *
216 * @return 1-31 or L.
217 */
218 public String getDayOfMonth()
219 {
220 return dayOfMonth;
221 }
222
223 /**
224 * Gets the {@see CronMinutesEntry} that represents the minutes cron field.
225 *
226 * @return the minutes part of the cron string.
227 */
228 public CronMinutesEntry getMinutesEntry()
229 {
230 return minutesEntry;
231 }
232
233 /**
234 * Gets the {@see CronHoursEntry} that represents the hours cron field.
235 *
236 * @return the hours part of the cron string.
237 */
238 public CronHoursEntry getHoursEntry()
239 {
240 return hoursEntry;
241 }
242
243 /**
244 * Gets the {@see CronDayOfWeekEntry} that represents the day of week cron field.
245 *
246 * @return the days of the week part of the cronstring.
247 */
248 public CronDayOfWeekEntry getDaysOfWeekEntry()
249 {
250 return daysOfWeekEntry;
251 }
252
253 /**
254 * Used to determine the total increment in minutes that are implied by the crons hour and minutes field. If
255 * the hours and minutes field have an increment then the increment will come into play. An increment of 0
256 * implies that the increment is once per day.
257 *
258 * @return the increment of repetition in minutes or 0 if there is no repetition.
259 */
260 public int getIncrementInMinutes()
261 {
262 return calculateIncrementInMinutes();
263 }
264
265 /**
266 * Note: if both hoursIncrement and minutesIncrement are set, and the hoursIncrement is more than 1, an increment
267 * for the whole cron expression is not supported by the Cron Editor UI (only regular increments are supported).
268 *
269 * For example, the cron expression 0 0/30 1-6/2 means "on the 1st, 3rd and 5th hours, at the 0th and 30th minute".
270 * This schedule is not of a regular period, due to the gaps in the 2nd and 4th hour, so it doesn't make sense to
271 * have an increment at all.
272 *
273 * But, we still need to return something - just return 0, since the increment value returned here should not be
274 * interpreted anywhere if the above case is true (Increment is only used for UI purposes when the editor is not in
275 * "advanced mode")
276 *
277 * @return the increment of repetition in minutes or 0 if there is no repetition.
278 */
279 private int calculateIncrementInMinutes()
280 {
281 int incrementInMinutes = 0;
282 final boolean minutesHasIncrement = minutesEntry.hasIncrement();
283 final boolean hoursHasIncrement = hoursEntry.hasIncrement();
284 final int minutesIncrement = minutesEntry.getIncrement();
285 final int hoursIncrement = hoursEntry.getIncrement();
286
287 if (minutesHasIncrement && hoursHasIncrement && hoursIncrement != 1)
288 {
289 incrementInMinutes = 0;
290 }
291 else if (minutesHasIncrement)
292 {
293 incrementInMinutes = minutesIncrement;
294 }
295 else if (hoursHasIncrement)
296 {
297 incrementInMinutes = hoursIncrement * MINUTES_IN_HOUR;
298 }
299
300 return incrementInMinutes;
301 }
302
303 private void parseAndValidateCronString(String cronString)
304 {
305 parseCronString(cronString);
306
307 // See if this cronstring is valid for the cron editor
308 updateEditorFlags();
309
310 if (!validForEditor)
311 {
312 // If the cronstring is invalid that setup the object to use the default cron values
313 parseCronString(DEFAULT_CRONSTRING);
314 }
315 }
316
317 private void parseCronString(String cronString)
318 {
319 StringTokenizer st = new StringTokenizer(cronString);
320 if (st.countTokens() != NUM_CRON_FIELDS && st.countTokens() != NUM_CRON_FIELDS_NO_YEAR)
321 {
322 throw new IllegalArgumentException("The provided cron string does not have " + NUM_CRON_FIELDS + " parts: " + cronString);
323 }
324
325 // Process the string
326
327 // Skip the seconds field, we don't care
328 this.seconds = st.nextToken();
329 String minutes = st.nextToken();
330 String hours = st.nextToken();
331 this.dayOfMonth = st.nextToken();
332 this.month = st.nextToken();
333 this.daysOfWeek = st.nextToken();
334 this.hoursEntry = new CronHoursEntry(hours);
335 this.minutesEntry = new CronMinutesEntry(minutes);
336 this.daysOfWeekEntry = new CronDayOfWeekEntry(daysOfWeek);
337 //check if year field was provided.
338 if(st.hasMoreTokens())
339 {
340 this.year = st.nextToken();
341 }
342 }
343
344 /**
345 * Sets the various flags (isDaily, isDayPerWeek, isDaysPerMonth, isAdvanced, validForEditor) based on the state of
346 * the parsed cron expression.
347 */
348 private void updateEditorFlags()
349 {
350 isDaily = (isWild(dayOfMonth) || notApplicable(dayOfMonth)) && isWild(month) && (isWild(daysOfWeek) || notApplicable(daysOfWeek));
351 isDayPerWeek = (isWild(dayOfMonth) || notApplicable(dayOfMonth)) && isWild(month) && daysOfWeekEntry.getDayInMonthOrdinal() == null && !isWild(daysOfWeek);
352
353 boolean numericDayOfMonth = !notApplicable(dayOfMonth) && !isWild(dayOfMonth) && isWild(month) && notApplicable(daysOfWeek) && StringUtils.containsOnly(dayOfMonth.toUpperCase(), VALID_DAY_OF_MONTH);
354 boolean dayOfWeekOfMonth = notApplicable(dayOfMonth) && isWild(month) && !isWild(daysOfWeek) && !notApplicable(daysOfWeek) && daysOfWeekEntry.getDayInMonthOrdinal() != null;
355 isDaysPerMonth = dayOfWeekOfMonth || numericDayOfMonth;
356
357 boolean isValidMode = isDaily || isDayPerWeek || isDaysPerMonth;
358
359 boolean hoursAndMinutesAreValid = hoursEntry.isValid() && minutesEntry.isValid();
360 boolean daysOfWeekAreValid = daysOfWeekEntry.isValid();
361
362 // JRA-13675: if an increment is specified for both hours and minutes, then the increments are only valid if
363 // the hours increment is 1. This forces the editor into Advanced Mode.
364 boolean incrementsValid = !(hoursEntry.hasIncrement() && minutesEntry.hasIncrement()) || hoursEntry.getIncrement() == 1;
365
366 // JRA-13503: if an increment is specified for minutes, and hours is run once (a single hour), this will
367 // eventually be interpretted as having an increment and therefore having a range. However, since the hours
368 // is constructed unaware of the minute increment, the From and To hour are not set to correctly represent this
369 // case (because the Run Once is set instead), and so future attempts to display this expression in the UI will
370 // produce incorrect results.
371 // We must flag this case as invalid for the editor defensively, so that we don't lose any detail when trying to
372 // display the expression in the UI. The alternative would be to modify the hoursEntry after the minutesEntry
373 // was created and take into account this case.
374 hoursAndMinutesAreValid = hoursAndMinutesAreValid && !(hoursEntry.isRunOnce() && minutesEntry.hasIncrement());
375
376 validForEditor = "0".equals(seconds) && isWild(month) && isValidMode && hoursAndMinutesAreValid && daysOfWeekAreValid && incrementsValid && StringUtils.isEmpty(year);
377 // If the string is not valid for the editor then we are in advanced mode and not in any other mode
378 if (!validForEditor)
379 {
380 isDaily = false;
381 isDayPerWeek = false;
382 isDaysPerMonth = false;
383 isAdvanced = true;
384 }
385 }
386
387 private boolean isWild(String expressionPart)
388 {
389 return WILDCARD.equals(expressionPart);
390 }
391
392 private boolean notApplicable(String expressionPart)
393 {
394 return NOT_APPLICABLE.equals(expressionPart);
395 }
396 }