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 }