001package biweekly.io.scribe.property;
002
003import java.util.ArrayList;
004import java.util.Arrays;
005import java.util.List;
006import java.util.Map;
007import java.util.regex.Matcher;
008import java.util.regex.Pattern;
009
010import org.w3c.dom.Element;
011
012import biweekly.ICalDataType;
013import biweekly.ICalVersion;
014import biweekly.Warning;
015import biweekly.component.ICalComponent;
016import biweekly.io.CannotParseException;
017import biweekly.io.ParseContext;
018import biweekly.io.TimezoneInfo;
019import biweekly.io.WriteContext;
020import biweekly.io.json.JCalValue;
021import biweekly.io.xml.XCalElement;
022import biweekly.io.xml.XCalNamespaceContext;
023import biweekly.parameter.ICalParameters;
024import biweekly.property.DateStart;
025import biweekly.property.RecurrenceProperty;
026import biweekly.util.ICalDate;
027import biweekly.util.ListMultimap;
028import biweekly.util.Recurrence;
029import biweekly.util.Recurrence.ByDay;
030import biweekly.util.Recurrence.DayOfWeek;
031import biweekly.util.Recurrence.Frequency;
032import biweekly.util.XmlUtils;
033
034/*
035 Copyright (c) 2013-2015, Michael Angstadt
036 All rights reserved.
037
038 Redistribution and use in source and binary forms, with or without
039 modification, are permitted provided that the following conditions are met: 
040
041 1. Redistributions of source code must retain the above copyright notice, this
042 list of conditions and the following disclaimer. 
043 2. Redistributions in binary form must reproduce the above copyright notice,
044 this list of conditions and the following disclaimer in the documentation
045 and/or other materials provided with the distribution. 
046
047 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
048 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
049 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
050 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
051 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
052 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
053 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
054 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
055 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
056 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
057 */
058
059/**
060 * Marshals properties whose values are {@link Recurrence}.
061 * @param <T> the property class
062 * @author Michael Angstadt
063 */
064public abstract class RecurrencePropertyScribe<T extends RecurrenceProperty> extends ICalPropertyScribe<T> {
065        private static final String FREQ = "FREQ";
066        private static final String UNTIL = "UNTIL";
067        private static final String COUNT = "COUNT";
068        private static final String INTERVAL = "INTERVAL";
069        private static final String BYSECOND = "BYSECOND";
070        private static final String BYMINUTE = "BYMINUTE";
071        private static final String BYHOUR = "BYHOUR";
072        private static final String BYDAY = "BYDAY";
073        private static final String BYMONTHDAY = "BYMONTHDAY";
074        private static final String BYYEARDAY = "BYYEARDAY";
075        private static final String BYWEEKNO = "BYWEEKNO";
076        private static final String BYMONTH = "BYMONTH";
077        private static final String BYSETPOS = "BYSETPOS";
078        private static final String WKST = "WKST";
079
080        public RecurrencePropertyScribe(Class<T> clazz, String propertyName) {
081                super(clazz, propertyName);
082        }
083
084        @Override
085        protected ICalDataType _defaultDataType(ICalVersion version) {
086                return ICalDataType.RECUR;
087        }
088
089        @Override
090        protected String _writeText(T property, WriteContext context) {
091                //null value
092                Recurrence recur = property.getValue();
093                if (recur == null) {
094                        return "";
095                }
096
097                //iCal 2.0
098                if (context.getVersion() != ICalVersion.V1_0) {
099                        ListMultimap<String, Object> components = buildComponents(property, context, false);
100                        return object(components.getMap());
101                }
102
103                //vCal 1.0
104                Frequency frequency = recur.getFrequency();
105                if (frequency == null) {
106                        return "";
107                }
108
109                StringBuilder sb = new StringBuilder();
110
111                Integer interval = recur.getInterval();
112                if (interval == null) {
113                        interval = 1;
114                }
115
116                switch (frequency) {
117                case YEARLY:
118                        if (!recur.getByMonth().isEmpty()) {
119                                sb.append("YM").append(interval);
120                                for (Integer month : recur.getByMonth()) {
121                                        sb.append(' ').append(month);
122                                }
123                        } else {
124                                sb.append("YD").append(interval);
125                                for (Integer day : recur.getByYearDay()) {
126                                        sb.append(' ').append(day);
127                                }
128                        }
129                        break;
130
131                case MONTHLY:
132                        if (!recur.getByMonthDay().isEmpty()) {
133                                sb.append("MD").append(interval);
134                                for (Integer day : recur.getByMonthDay()) {
135                                        sb.append(' ').append(writeVCalInt(day));
136                                }
137                        } else {
138                                sb.append("MP").append(interval);
139                                for (ByDay byDay : recur.getByDay()) {
140                                        DayOfWeek day = byDay.getDay();
141                                        Integer prefix = byDay.getNum();
142                                        if (prefix == null) {
143                                                prefix = 1;
144                                        }
145
146                                        sb.append(' ').append(writeVCalInt(prefix)).append(' ').append(day.getAbbr());
147                                }
148                        }
149                        break;
150
151                case WEEKLY:
152                        sb.append("W").append(interval);
153                        for (ByDay byDay : recur.getByDay()) {
154                                sb.append(' ').append(byDay.getDay().getAbbr());
155                        }
156                        break;
157
158                case DAILY:
159                        sb.append("D").append(interval);
160                        break;
161
162                case HOURLY:
163                        sb.append("M").append(interval * 60);
164                        break;
165
166                case MINUTELY:
167                        sb.append("M").append(interval);
168                        break;
169
170                default:
171                        return "";
172                }
173
174                Integer count = recur.getCount();
175                ICalDate until = recur.getUntil();
176                sb.append(' ');
177
178                if (count != null) {
179                        sb.append('#').append(count);
180                } else if (until != null) {
181                        String dateStr = date(until, property, context).extended(false).write();
182                        sb.append(dateStr);
183                } else {
184                        sb.append("#0");
185                }
186
187                return sb.toString();
188        }
189
190        @Override
191        protected T _parseText(String value, ICalDataType dataType, ICalParameters parameters, ParseContext context) {
192                final Recurrence.Builder builder = new Recurrence.Builder((Frequency) null);
193
194                if (value.length() == 0) {
195                        return newInstance(builder.build());
196                }
197
198                if (context.getVersion() != ICalVersion.V1_0) {
199                        ListMultimap<String, String> rules = object(value);
200
201                        List<Warning> warnings = context.getWarnings();
202                        parseFreq(rules, builder, warnings);
203                        parseUntil(rules, builder, warnings);
204                        parseCount(rules, builder, warnings);
205                        parseInterval(rules, builder, warnings);
206                        parseBySecond(rules, builder, warnings);
207                        parseByMinute(rules, builder, warnings);
208                        parseByHour(rules, builder, warnings);
209                        parseByDay(rules, builder, warnings);
210                        parseByMonthDay(rules, builder, warnings);
211                        parseByYearDay(rules, builder, warnings);
212                        parseByWeekNo(rules, builder, warnings);
213                        parseByMonth(rules, builder, warnings);
214                        parseBySetPos(rules, builder, warnings);
215                        parseWkst(rules, builder, warnings);
216                        parseXRules(rules, builder, warnings); //must be called last
217
218                        T property = newInstance(builder.build());
219
220                        ICalDate until = property.getValue().getUntil();
221                        if (until != null) {
222                                context.addDate(until, property, parameters);
223                        }
224
225                        return property;
226                }
227
228                String splitValues[] = value.toUpperCase().split("\\s+");
229
230                //parse the frequency and interval from the first token (e.g. "W2")
231                String frequencyStr;
232                Integer interval;
233                {
234                        String firstToken = splitValues[0];
235                        Pattern p = Pattern.compile("^([A-Z]+)(\\d+)$");
236                        Matcher m = p.matcher(firstToken);
237                        if (!m.find()) {
238                                throw new CannotParseException("Invalid token: " + firstToken);
239                        }
240
241                        frequencyStr = m.group(1);
242                        interval = Integer.valueOf(m.group(2));
243                        splitValues = Arrays.copyOfRange(splitValues, 1, splitValues.length);
244                }
245                builder.interval(interval);
246
247                Integer count = null;
248                ICalDate until = null;
249                if (splitValues.length == 0) {
250                        count = 2;
251                } else {
252                        String lastToken = splitValues[splitValues.length - 1];
253                        if (lastToken.startsWith("#")) {
254                                String countStr = lastToken.substring(1, lastToken.length());
255                                count = Integer.valueOf(countStr);
256                                if (count == 0) {
257                                        //infinite
258                                        count = null;
259                                }
260
261                                splitValues = Arrays.copyOfRange(splitValues, 0, splitValues.length - 1);
262                        } else {
263                                try {
264                                        //see if the value is an "until" date
265                                        until = date(lastToken).parse();
266                                        splitValues = Arrays.copyOfRange(splitValues, 0, splitValues.length - 1);
267                                } catch (IllegalArgumentException e) {
268                                        //last token is a regular value
269                                        count = 2;
270                                }
271                        }
272                }
273                builder.count(count);
274                builder.until(until);
275
276                //determine what frequency enum to use and how to treat each tokenized value
277                Frequency frequency = null;
278                Handler<String> handler = null;
279                if ("YD".equals(frequencyStr)) {
280                        frequency = Frequency.YEARLY;
281                        handler = new Handler<String>() {
282                                public void handle(String value) {
283                                        if (value == null) {
284                                                return;
285                                        }
286
287                                        Integer dayOfYear = Integer.valueOf(value);
288                                        builder.byYearDay(dayOfYear);
289                                }
290                        };
291                } else if ("YM".equals(frequencyStr)) {
292                        frequency = Frequency.YEARLY;
293                        handler = new Handler<String>() {
294                                public void handle(String value) {
295                                        if (value == null) {
296                                                return;
297                                        }
298
299                                        Integer month = Integer.valueOf(value);
300                                        builder.byMonth(month);
301                                }
302                        };
303                } else if ("MD".equals(frequencyStr)) {
304                        frequency = Frequency.MONTHLY;
305                        handler = new Handler<String>() {
306                                public void handle(String value) {
307                                        if (value == null) {
308                                                return;
309                                        }
310
311                                        Integer date = "LD".equals(value) ? -1 : parseVCalInt(value);
312                                        builder.byMonthDay(date);
313                                }
314                        };
315                } else if ("MP".equals(frequencyStr)) {
316                        frequency = Frequency.MONTHLY;
317                        handler = new Handler<String>() {
318                                private final List<Integer> nums = new ArrayList<Integer>();
319                                private final List<DayOfWeek> days = new ArrayList<DayOfWeek>();
320                                private boolean readNum = false;
321
322                                public void handle(String value) {
323                                        if (value == null) {
324                                                //end of list
325                                                for (Integer num : nums) {
326                                                        for (DayOfWeek day : days) {
327                                                                builder.byDay(num, day);
328                                                        }
329                                                }
330                                                return;
331                                        }
332
333                                        if (value.matches("\\d{4}")) {
334                                                readNum = false;
335
336                                                Integer hour = Integer.valueOf(value.substring(0, 2));
337                                                builder.byHour(hour);
338
339                                                Integer minute = Integer.valueOf(value.substring(2, 4));
340                                                builder.byMinute(minute);
341                                                return;
342                                        }
343
344                                        try {
345                                                Integer curNum = parseVCalInt(value);
346
347                                                if (!readNum) {
348                                                        //reset lists, new segment
349                                                        for (Integer num : nums) {
350                                                                for (DayOfWeek day : days) {
351                                                                        builder.byDay(num, day);
352                                                                }
353                                                        }
354                                                        nums.clear();
355                                                        days.clear();
356
357                                                        readNum = true;
358                                                }
359
360                                                nums.add(curNum);
361                                        } catch (NumberFormatException e) {
362                                                readNum = false;
363
364                                                DayOfWeek day = parseDay(value);
365                                                days.add(day);
366                                        }
367                                }
368                        };
369                } else if ("W".equals(frequencyStr)) {
370                        frequency = Frequency.WEEKLY;
371                        handler = new Handler<String>() {
372                                public void handle(String value) {
373                                        if (value == null) {
374                                                return;
375                                        }
376
377                                        DayOfWeek day = parseDay(value);
378                                        builder.byDay(day);
379                                }
380                        };
381                } else if ("D".equals(frequencyStr)) {
382                        frequency = Frequency.DAILY;
383                        handler = new Handler<String>() {
384                                public void handle(String value) {
385                                        if (value == null) {
386                                                return;
387                                        }
388
389                                        Integer hour = Integer.valueOf(value.substring(0, 2));
390                                        builder.byHour(hour);
391
392                                        Integer minute = Integer.valueOf(value.substring(2, 4));
393                                        builder.byMinute(minute);
394                                }
395                        };
396                } else if ("M".equals(frequencyStr)) {
397                        frequency = Frequency.MINUTELY;
398                        handler = new Handler<String>() {
399                                public void handle(String value) {
400                                        //TODO can this ever have values?
401                                }
402                        };
403                } else {
404                        throw new CannotParseException("Unrecognized frequency: " + frequencyStr);
405                }
406
407                builder.frequency(frequency);
408
409                //parse the rest of the tokens
410                for (String splitValue : splitValues) {
411                        //TODO not sure how to handle the "$" symbol, ignore it
412                        if (splitValue.endsWith("$")) {
413                                context.addWarning(36, splitValue);
414                                splitValue = splitValue.substring(0, splitValue.length() - 1);
415                        }
416
417                        handler.handle(splitValue);
418                }
419                handler.handle(null);
420
421                T property = newInstance(builder.build());
422                if (until != null) {
423                        context.addDate(until, property, parameters);
424                }
425
426                return property;
427        }
428
429        private int parseVCalInt(String value) {
430                int negate = 1;
431                if (value.endsWith("+")) {
432                        value = value.substring(0, value.length() - 1);
433                } else if (value.endsWith("-")) {
434                        value = value.substring(0, value.length() - 1);
435                        negate = -1;
436                }
437
438                return Integer.parseInt(value) * negate;
439        }
440
441        private String writeVCalInt(Integer value) {
442                if (value > 0) {
443                        return value + "+";
444                }
445
446                if (value < 0) {
447                        return Math.abs(value) + "-";
448                }
449
450                return value + "";
451        }
452
453        private DayOfWeek parseDay(String value) {
454                DayOfWeek day = DayOfWeek.valueOfAbbr(value);
455                if (day == null) {
456                        throw new CannotParseException("Invalid day: " + value);
457                }
458
459                return day;
460        }
461
462        @Override
463        protected void _writeXml(T property, XCalElement element, WriteContext context) {
464                XCalElement recurElement = element.append(dataType(property, null));
465
466                Recurrence recur = property.getValue();
467                if (recur == null) {
468                        return;
469                }
470
471                ListMultimap<String, Object> components = buildComponents(property, context, true);
472                for (Map.Entry<String, List<Object>> component : components) {
473                        String name = component.getKey().toLowerCase();
474                        for (Object value : component.getValue()) {
475                                recurElement.append(name, value.toString());
476                        }
477                }
478        }
479
480        @Override
481        protected T _parseXml(XCalElement element, ICalParameters parameters, ParseContext context) {
482                ICalDataType dataType = defaultDataType(context.getVersion());
483                XCalElement value = element.child(dataType);
484                if (value == null) {
485                        throw missingXmlElements(dataType);
486                }
487
488                ListMultimap<String, String> rules = new ListMultimap<String, String>();
489                for (Element child : XmlUtils.toElementList(value.getElement().getChildNodes())) {
490                        if (!XCalNamespaceContext.XCAL_NS.equals(child.getNamespaceURI())) {
491                                continue;
492                        }
493
494                        String name = child.getLocalName().toUpperCase();
495                        String text = child.getTextContent();
496                        rules.put(name, text);
497                }
498
499                Recurrence.Builder builder = new Recurrence.Builder((Frequency) null);
500
501                List<Warning> warnings = context.getWarnings();
502                parseFreq(rules, builder, warnings);
503                parseUntil(rules, builder, warnings);
504                parseCount(rules, builder, warnings);
505                parseInterval(rules, builder, warnings);
506                parseBySecond(rules, builder, warnings);
507                parseByMinute(rules, builder, warnings);
508                parseByHour(rules, builder, warnings);
509                parseByDay(rules, builder, warnings);
510                parseByMonthDay(rules, builder, warnings);
511                parseByYearDay(rules, builder, warnings);
512                parseByWeekNo(rules, builder, warnings);
513                parseByMonth(rules, builder, warnings);
514                parseBySetPos(rules, builder, warnings);
515                parseWkst(rules, builder, warnings);
516                parseXRules(rules, builder, warnings); //must be called last
517
518                T property = newInstance(builder.build());
519
520                ICalDate until = property.getValue().getUntil();
521                if (until != null) {
522                        context.addDate(until, property, parameters);
523                }
524
525                return property;
526        }
527
528        @Override
529        protected JCalValue _writeJson(T property, WriteContext context) {
530                Recurrence recur = property.getValue();
531                if (recur == null) {
532                        return JCalValue.object(new ListMultimap<String, Object>(0));
533                }
534
535                ListMultimap<String, Object> components = buildComponents(property, context, true);
536
537                //lower-case all the keys
538                ListMultimap<String, Object> object = new ListMultimap<String, Object>(components.keySet().size());
539                for (Map.Entry<String, List<Object>> entry : components) {
540                        String key = entry.getKey().toLowerCase();
541                        object.putAll(key, entry.getValue());
542                }
543
544                return JCalValue.object(object);
545        }
546
547        @Override
548        protected T _parseJson(JCalValue value, ICalDataType dataType, ICalParameters parameters, ParseContext context) {
549                Recurrence.Builder builder = new Recurrence.Builder((Frequency) null);
550
551                //upper-case the keys
552                ListMultimap<String, String> object = value.asObject();
553                ListMultimap<String, String> rules = new ListMultimap<String, String>(object.keySet().size());
554                for (Map.Entry<String, List<String>> entry : object) {
555                        String key = entry.getKey().toUpperCase();
556                        rules.putAll(key, entry.getValue());
557                }
558
559                List<Warning> warnings = context.getWarnings();
560                parseFreq(rules, builder, warnings);
561                parseUntil(rules, builder, warnings);
562                parseCount(rules, builder, warnings);
563                parseInterval(rules, builder, warnings);
564                parseBySecond(rules, builder, warnings);
565                parseByMinute(rules, builder, warnings);
566                parseByHour(rules, builder, warnings);
567                parseByDay(rules, builder, warnings);
568                parseByMonthDay(rules, builder, warnings);
569                parseByYearDay(rules, builder, warnings);
570                parseByWeekNo(rules, builder, warnings);
571                parseByMonth(rules, builder, warnings);
572                parseBySetPos(rules, builder, warnings);
573                parseWkst(rules, builder, warnings);
574                parseXRules(rules, builder, warnings); //must be called last
575
576                T property = newInstance(builder.build());
577
578                ICalDate until = property.getValue().getUntil();
579                if (until != null) {
580                        context.addDate(until, property, parameters);
581                }
582
583                return property;
584        }
585
586        /**
587         * Creates a new instance of the recurrence property.
588         * @param recur the recurrence value
589         * @return the new instance
590         */
591        protected abstract T newInstance(Recurrence recur);
592
593        private void parseFreq(ListMultimap<String, String> rules, final Recurrence.Builder builder, final List<Warning> warnings) {
594                parseFirst(rules, FREQ, new Handler<String>() {
595                        public void handle(String value) {
596                                value = value.toUpperCase();
597                                try {
598                                        Frequency frequency = Frequency.valueOf(value);
599                                        builder.frequency(frequency);
600                                } catch (IllegalArgumentException e) {
601                                        warnings.add(Warning.parse(7, FREQ, value));
602                                }
603                        }
604                });
605        }
606
607        private void parseUntil(ListMultimap<String, String> rules, final Recurrence.Builder builder, final List<Warning> warnings) {
608                parseFirst(rules, UNTIL, new Handler<String>() {
609                        public void handle(String value) {
610                                try {
611                                        ICalDate date = date(value).parse();
612                                        builder.until(date);
613                                } catch (IllegalArgumentException e) {
614                                        warnings.add(Warning.parse(7, UNTIL, value));
615                                }
616                        }
617                });
618        }
619
620        private void parseCount(ListMultimap<String, String> rules, final Recurrence.Builder builder, final List<Warning> warnings) {
621                parseFirst(rules, COUNT, new Handler<String>() {
622                        public void handle(String value) {
623                                try {
624                                        builder.count(Integer.valueOf(value));
625                                } catch (NumberFormatException e) {
626                                        warnings.add(Warning.parse(7, COUNT, value));
627                                }
628                        }
629                });
630        }
631
632        private void parseInterval(ListMultimap<String, String> rules, final Recurrence.Builder builder, final List<Warning> warnings) {
633                parseFirst(rules, INTERVAL, new Handler<String>() {
634                        public void handle(String value) {
635                                try {
636                                        builder.interval(Integer.valueOf(value));
637                                } catch (NumberFormatException e) {
638                                        warnings.add(Warning.parse(7, INTERVAL, value));
639                                }
640                        }
641                });
642        }
643
644        private void parseBySecond(ListMultimap<String, String> rules, final Recurrence.Builder builder, List<Warning> warnings) {
645                parseIntegerList(BYSECOND, rules, warnings, new Handler<Integer>() {
646                        public void handle(Integer value) {
647                                builder.bySecond(value);
648                        }
649                });
650        }
651
652        private void parseByMinute(ListMultimap<String, String> rules, final Recurrence.Builder builder, List<Warning> warnings) {
653                parseIntegerList(BYMINUTE, rules, warnings, new Handler<Integer>() {
654                        public void handle(Integer value) {
655                                builder.byMinute(value);
656                        }
657                });
658        }
659
660        private void parseByHour(ListMultimap<String, String> rules, final Recurrence.Builder builder, List<Warning> warnings) {
661                parseIntegerList(BYHOUR, rules, warnings, new Handler<Integer>() {
662                        public void handle(Integer value) {
663                                builder.byHour(value);
664                        }
665                });
666        }
667
668        private void parseByDay(ListMultimap<String, String> rules, Recurrence.Builder builder, List<Warning> warnings) {
669                Pattern p = Pattern.compile("^([-+]?\\d+)?(.*)$");
670                for (String value : rules.removeAll(BYDAY)) {
671                        Matcher m = p.matcher(value);
672                        if (!m.find()) {
673                                //this should never happen
674                                //the regex contains a "match-all" pattern and should never not find anything
675                                warnings.add(Warning.parse(7, BYDAY, value));
676                                continue;
677                        }
678
679                        String dayStr = m.group(2);
680                        DayOfWeek day = DayOfWeek.valueOfAbbr(dayStr);
681                        if (day == null) {
682                                warnings.add(Warning.parse(7, BYDAY, value));
683                                continue;
684                        }
685
686                        String prefixStr = m.group(1);
687                        Integer prefix = (prefixStr == null) ? null : Integer.valueOf(prefixStr); //no need to catch NumberFormatException because the regex guarantees that it will be a number
688
689                        builder.byDay(prefix, day);
690                }
691        }
692
693        private void parseByMonthDay(ListMultimap<String, String> rules, final Recurrence.Builder builder, List<Warning> warnings) {
694                parseIntegerList(BYMONTHDAY, rules, warnings, new Handler<Integer>() {
695                        public void handle(Integer value) {
696                                builder.byMonthDay(value);
697                        }
698                });
699        }
700
701        private void parseByYearDay(ListMultimap<String, String> rules, final Recurrence.Builder builder, List<Warning> warnings) {
702                parseIntegerList(BYYEARDAY, rules, warnings, new Handler<Integer>() {
703                        public void handle(Integer value) {
704                                builder.byYearDay(value);
705                        }
706                });
707        }
708
709        private void parseByWeekNo(ListMultimap<String, String> rules, final Recurrence.Builder builder, List<Warning> warnings) {
710                parseIntegerList(BYWEEKNO, rules, warnings, new Handler<Integer>() {
711                        public void handle(Integer value) {
712                                builder.byWeekNo(value);
713                        }
714                });
715        }
716
717        private void parseByMonth(ListMultimap<String, String> rules, final Recurrence.Builder builder, List<Warning> warnings) {
718                parseIntegerList(BYMONTH, rules, warnings, new Handler<Integer>() {
719                        public void handle(Integer value) {
720                                builder.byMonth(value);
721                        }
722                });
723        }
724
725        private void parseBySetPos(ListMultimap<String, String> rules, final Recurrence.Builder builder, List<Warning> warnings) {
726                parseIntegerList(BYSETPOS, rules, warnings, new Handler<Integer>() {
727                        public void handle(Integer value) {
728                                builder.bySetPos(value);
729                        }
730                });
731        }
732
733        private void parseWkst(ListMultimap<String, String> rules, final Recurrence.Builder builder, final List<Warning> warnings) {
734                parseFirst(rules, WKST, new Handler<String>() {
735                        public void handle(String value) {
736                                DayOfWeek day = DayOfWeek.valueOfAbbr(value);
737                                if (day == null) {
738                                        warnings.add(Warning.parse(7, WKST, value));
739                                        return;
740                                }
741
742                                builder.workweekStarts(day);
743                        }
744                });
745        }
746
747        private void parseXRules(ListMultimap<String, String> rules, Recurrence.Builder builder, List<Warning> warnings) {
748                for (Map.Entry<String, List<String>> rule : rules) {
749                        String name = rule.getKey();
750                        for (String value : rule.getValue()) {
751                                builder.xrule(name, value);
752                        }
753                }
754        }
755
756        private ListMultimap<String, Object> buildComponents(T property, WriteContext context, boolean extended) {
757                ListMultimap<String, Object> components = new ListMultimap<String, Object>();
758                Recurrence recur = property.getValue();
759
760                //FREQ must come first
761                if (recur.getFrequency() != null) {
762                        components.put(FREQ, recur.getFrequency().name());
763                }
764
765                ICalDate until = recur.getUntil();
766                if (until != null) {
767                        components.put(UNTIL, writeUntil(until, context, extended));
768                }
769
770                if (recur.getCount() != null) {
771                        components.put(COUNT, recur.getCount());
772                }
773
774                if (recur.getInterval() != null) {
775                        components.put(INTERVAL, recur.getInterval());
776                }
777
778                addIntegerListComponent(components, BYSECOND, recur.getBySecond());
779                addIntegerListComponent(components, BYMINUTE, recur.getByMinute());
780                addIntegerListComponent(components, BYHOUR, recur.getByHour());
781
782                for (ByDay byDay : recur.getByDay()) {
783                        Integer prefix = byDay.getNum();
784                        DayOfWeek day = byDay.getDay();
785
786                        String value = day.getAbbr();
787                        if (prefix != null) {
788                                value = prefix + value;
789                        }
790                        components.put(BYDAY, value);
791                }
792
793                addIntegerListComponent(components, BYMONTHDAY, recur.getByMonthDay());
794                addIntegerListComponent(components, BYYEARDAY, recur.getByYearDay());
795                addIntegerListComponent(components, BYWEEKNO, recur.getByWeekNo());
796                addIntegerListComponent(components, BYMONTH, recur.getByMonth());
797                addIntegerListComponent(components, BYSETPOS, recur.getBySetPos());
798
799                if (recur.getWorkweekStarts() != null) {
800                        components.put(WKST, recur.getWorkweekStarts().getAbbr());
801                }
802
803                for (Map.Entry<String, List<String>> entry : recur.getXRules().entrySet()) {
804                        String name = entry.getKey();
805                        for (String value : entry.getValue()) {
806                                components.put(name, value);
807                        }
808                }
809
810                return components;
811        }
812
813        private String writeUntil(ICalDate until, WriteContext context, boolean extended) {
814                if (!until.hasTime()) {
815                        return date(until).extended(extended).write();
816                }
817
818                /*
819                 * RFC 5545 p.41
820                 * 
821                 * In the case of the "STANDARD" and "DAYLIGHT" sub-components the UNTIL
822                 * rule part MUST always be specified as a date with UTC time. If
823                 * specified as a DATE-TIME value, then it MUST be specified in a UTC
824                 * time format.
825                 */
826
827                if (isInObservance(context)) {
828                        return date(until).utc(true).extended(extended).write();
829                }
830
831                /*
832                 * RFC 2445 p.42
833                 * 
834                 * If specified as a date-time value, then it MUST be specified in an
835                 * UTC time format.
836                 */
837                if (context.getVersion() == ICalVersion.V2_0_DEPRECATED) {
838                        return date(until).extended(extended).utc(true).write();
839                }
840
841                /*
842                 * RFC 5545 p.41
843                 * 
844                 * Furthermore, if the "DTSTART" property is specified as a date
845                 * with local time, then the UNTIL rule part MUST also be
846                 * specified as a date with local time. If the "DTSTART"
847                 * property is specified as a date with UTC time or a date with
848                 * local time and time zone reference, then the UNTIL rule part
849                 * MUST be specified as a date with UTC time.
850                 */
851
852                ICalComponent parent = context.getParent();
853                if (parent == null) {
854                        return date(until).extended(extended).utc(true).write();
855                }
856
857                DateStart dtstart = parent.getProperty(DateStart.class);
858                if (dtstart == null) {
859                        return date(until).extended(extended).utc(true).write();
860                }
861
862                /*
863                 * If DTSTART is floating, then UNTIL should be floating.
864                 */
865                TimezoneInfo tzinfo = context.getTimezoneInfo();
866                boolean dtstartFloating = tzinfo.isFloating(dtstart);
867                if (dtstartFloating) {
868                        return date(until).extended(extended).tz(true, null).write();
869                }
870
871                /*
872                 * Otherwise, UNTIL should be UTC.
873                 */
874                return date(until).extended(extended).utc(true).write();
875        }
876
877        private void addIntegerListComponent(ListMultimap<String, Object> components, String name, List<Integer> values) {
878                for (Integer value : values) {
879                        components.put(name, value);
880                }
881        }
882
883        private void parseFirst(ListMultimap<String, String> rules, String name, Handler<String> handler) {
884                List<String> values = rules.removeAll(name);
885                if (values.isEmpty()) {
886                        return;
887                }
888
889                String value = values.get(0);
890                handler.handle(value);
891        }
892
893        private void parseIntegerList(String name, ListMultimap<String, String> rules, List<Warning> warnings, Handler<Integer> handler) {
894                List<String> values = rules.removeAll(name);
895                for (String value : values) {
896                        try {
897                                handler.handle(Integer.valueOf(value));
898                        } catch (NumberFormatException e) {
899                                warnings.add(Warning.parse(8, name, value));
900                        }
901                }
902        }
903
904        private interface Handler<T> {
905                void handle(T value);
906        }
907}