1/*
2 * Copyright (C) 2007 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.calendarcommon2;
18
19import android.util.Log;
20
21import java.util.LinkedHashMap;
22import java.util.LinkedList;
23import java.util.List;
24import java.util.Set;
25import java.util.ArrayList;
26
27/**
28 * Parses RFC 2445 iCalendar objects.
29 */
30public class ICalendar {
31
32    private static final String TAG = "Sync";
33
34    // TODO: keep track of VEVENT, VTODO, VJOURNAL, VFREEBUSY, VTIMEZONE, VALARM
35    // components, by type field or by subclass?  subclass would allow us to
36    // enforce grammars.
37
38    /**
39     * Exception thrown when an iCalendar object has invalid syntax.
40     */
41    public static class FormatException extends Exception {
42        public FormatException() {
43            super();
44        }
45
46        public FormatException(String msg) {
47            super(msg);
48        }
49
50        public FormatException(String msg, Throwable cause) {
51            super(msg, cause);
52        }
53    }
54
55    /**
56     * A component within an iCalendar (VEVENT, VTODO, VJOURNAL, VFEEBUSY,
57     * VTIMEZONE, VALARM).
58     */
59    public static class Component {
60
61        // components
62        static final String BEGIN = "BEGIN";
63        static final String END = "END";
64        private static final String NEWLINE = "\n";
65        public static final String VCALENDAR = "VCALENDAR";
66        public static final String VEVENT = "VEVENT";
67        public static final String VTODO = "VTODO";
68        public static final String VJOURNAL = "VJOURNAL";
69        public static final String VFREEBUSY = "VFREEBUSY";
70        public static final String VTIMEZONE = "VTIMEZONE";
71        public static final String VALARM = "VALARM";
72
73        private final String mName;
74        private final Component mParent; // see if we can get rid of this
75        private LinkedList<Component> mChildren = null;
76        private final LinkedHashMap<String, ArrayList<Property>> mPropsMap =
77                new LinkedHashMap<String, ArrayList<Property>>();
78
79        /**
80         * Creates a new component with the provided name.
81         * @param name The name of the component.
82         */
83        public Component(String name, Component parent) {
84            mName = name;
85            mParent = parent;
86        }
87
88        /**
89         * Returns the name of the component.
90         * @return The name of the component.
91         */
92        public String getName() {
93            return mName;
94        }
95
96        /**
97         * Returns the parent of this component.
98         * @return The parent of this component.
99         */
100        public Component getParent() {
101            return mParent;
102        }
103
104        /**
105         * Helper that lazily gets/creates the list of children.
106         * @return The list of children.
107         */
108        protected LinkedList<Component> getOrCreateChildren() {
109            if (mChildren == null) {
110                mChildren = new LinkedList<Component>();
111            }
112            return mChildren;
113        }
114
115        /**
116         * Adds a child component to this component.
117         * @param child The child component.
118         */
119        public void addChild(Component child) {
120            getOrCreateChildren().add(child);
121        }
122
123        /**
124         * Returns a list of the Component children of this component.  May be
125         * null, if there are no children.
126         *
127         * @return A list of the children.
128         */
129        public List<Component> getComponents() {
130            return mChildren;
131        }
132
133        /**
134         * Adds a Property to this component.
135         * @param prop
136         */
137        public void addProperty(Property prop) {
138            String name= prop.getName();
139            ArrayList<Property> props = mPropsMap.get(name);
140            if (props == null) {
141                props = new ArrayList<Property>();
142                mPropsMap.put(name, props);
143            }
144            props.add(prop);
145        }
146
147        /**
148         * Returns a set of the property names within this component.
149         * @return A set of property names within this component.
150         */
151        public Set<String> getPropertyNames() {
152            return mPropsMap.keySet();
153        }
154
155        /**
156         * Returns a list of properties with the specified name.  Returns null
157         * if there are no such properties.
158         * @param name The name of the property that should be returned.
159         * @return A list of properties with the requested name.
160         */
161        public List<Property> getProperties(String name) {
162            return mPropsMap.get(name);
163        }
164
165        /**
166         * Returns the first property with the specified name.  Returns null
167         * if there is no such property.
168         * @param name The name of the property that should be returned.
169         * @return The first property with the specified name.
170         */
171        public Property getFirstProperty(String name) {
172            List<Property> props = mPropsMap.get(name);
173            if (props == null || props.size() == 0) {
174                return null;
175            }
176            return props.get(0);
177        }
178
179        @Override
180        public String toString() {
181            StringBuilder sb = new StringBuilder();
182            toString(sb);
183            sb.append(NEWLINE);
184            return sb.toString();
185        }
186
187        /**
188         * Helper method that appends this component to a StringBuilder.  The
189         * caller is responsible for appending a newline at the end of the
190         * component.
191         */
192        public void toString(StringBuilder sb) {
193            sb.append(BEGIN);
194            sb.append(":");
195            sb.append(mName);
196            sb.append(NEWLINE);
197
198            // append the properties
199            for (String propertyName : getPropertyNames()) {
200                for (Property property : getProperties(propertyName)) {
201                    property.toString(sb);
202                    sb.append(NEWLINE);
203                }
204            }
205
206            // append the sub-components
207            if (mChildren != null) {
208                for (Component component : mChildren) {
209                    component.toString(sb);
210                    sb.append(NEWLINE);
211                }
212            }
213
214            sb.append(END);
215            sb.append(":");
216            sb.append(mName);
217        }
218    }
219
220    /**
221     * A property within an iCalendar component (e.g., DTSTART, DTEND, etc.,
222     * within a VEVENT).
223     */
224    public static class Property {
225        // properties
226        // TODO: do we want to list these here?  the complete list is long.
227        public static final String DTSTART = "DTSTART";
228        public static final String DTEND = "DTEND";
229        public static final String DURATION = "DURATION";
230        public static final String RRULE = "RRULE";
231        public static final String RDATE = "RDATE";
232        public static final String EXRULE = "EXRULE";
233        public static final String EXDATE = "EXDATE";
234        // ... need to add more.
235
236        private final String mName;
237        private LinkedHashMap<String, ArrayList<Parameter>> mParamsMap =
238                new LinkedHashMap<String, ArrayList<Parameter>>();
239        private String mValue; // TODO: make this final?
240
241        /**
242         * Creates a new property with the provided name.
243         * @param name The name of the property.
244         */
245        public Property(String name) {
246            mName = name;
247        }
248
249        /**
250         * Creates a new property with the provided name and value.
251         * @param name The name of the property.
252         * @param value The value of the property.
253         */
254        public Property(String name, String value) {
255            mName = name;
256            mValue = value;
257        }
258
259        /**
260         * Returns the name of the property.
261         * @return The name of the property.
262         */
263        public String getName() {
264            return mName;
265        }
266
267        /**
268         * Returns the value of this property.
269         * @return The value of this property.
270         */
271        public String getValue() {
272            return mValue;
273        }
274
275        /**
276         * Sets the value of this property.
277         * @param value The desired value for this property.
278         */
279        public void setValue(String value) {
280            mValue = value;
281        }
282
283        /**
284         * Adds a {@link Parameter} to this property.
285         * @param param The parameter that should be added.
286         */
287        public void addParameter(Parameter param) {
288            ArrayList<Parameter> params = mParamsMap.get(param.name);
289            if (params == null) {
290                params = new ArrayList<Parameter>();
291                mParamsMap.put(param.name, params);
292            }
293            params.add(param);
294        }
295
296        /**
297         * Returns the set of parameter names for this property.
298         * @return The set of parameter names for this property.
299         */
300        public Set<String> getParameterNames() {
301            return mParamsMap.keySet();
302        }
303
304        /**
305         * Returns the list of parameters with the specified name.  May return
306         * null if there are no such parameters.
307         * @param name The name of the parameters that should be returned.
308         * @return The list of parameters with the specified name.
309         */
310        public List<Parameter> getParameters(String name) {
311            return mParamsMap.get(name);
312        }
313
314        /**
315         * Returns the first parameter with the specified name.  May return
316         * nll if there is no such parameter.
317         * @param name The name of the parameter that should be returned.
318         * @return The first parameter with the specified name.
319         */
320        public Parameter getFirstParameter(String name) {
321            ArrayList<Parameter> params = mParamsMap.get(name);
322            if (params == null || params.size() == 0) {
323                return null;
324            }
325            return params.get(0);
326        }
327
328        @Override
329        public String toString() {
330            StringBuilder sb = new StringBuilder();
331            toString(sb);
332            return sb.toString();
333        }
334
335        /**
336         * Helper method that appends this property to a StringBuilder.  The
337         * caller is responsible for appending a newline after this property.
338         */
339        public void toString(StringBuilder sb) {
340            sb.append(mName);
341            Set<String> parameterNames = getParameterNames();
342            for (String parameterName : parameterNames) {
343                for (Parameter param : getParameters(parameterName)) {
344                    sb.append(";");
345                    param.toString(sb);
346                }
347            }
348            sb.append(":");
349            sb.append(mValue);
350        }
351    }
352
353    /**
354     * A parameter defined for an iCalendar property.
355     */
356    // TODO: make this a proper class rather than a struct?
357    public static class Parameter {
358        public String name;
359        public String value;
360
361        /**
362         * Creates a new empty parameter.
363         */
364        public Parameter() {
365        }
366
367        /**
368         * Creates a new parameter with the specified name and value.
369         * @param name The name of the parameter.
370         * @param value The value of the parameter.
371         */
372        public Parameter(String name, String value) {
373            this.name = name;
374            this.value = value;
375        }
376
377        @Override
378        public String toString() {
379            StringBuilder sb = new StringBuilder();
380            toString(sb);
381            return sb.toString();
382        }
383
384        /**
385         * Helper method that appends this parameter to a StringBuilder.
386         */
387        public void toString(StringBuilder sb) {
388            sb.append(name);
389            sb.append("=");
390            sb.append(value);
391        }
392    }
393
394    private static final class ParserState {
395        // public int lineNumber = 0;
396        public String line; // TODO: just point to original text
397        public int index;
398    }
399
400    // use factory method
401    private ICalendar() {
402    }
403
404    // TODO: get rid of this -- handle all of the parsing in one pass through
405    // the text.
406    private static String normalizeText(String text) {
407        // it's supposed to be \r\n, but not everyone does that
408        text = text.replaceAll("\r\n", "\n");
409        text = text.replaceAll("\r", "\n");
410
411        // we deal with line folding, by replacing all "\n " strings
412        // with nothing.  The RFC specifies "\r\n " to be folded, but
413        // we handle "\n " and "\r " too because we can get those.
414        text = text.replaceAll("\n ", "");
415
416        return text;
417    }
418
419    /**
420     * Parses text into an iCalendar component.  Parses into the provided
421     * component, if not null, or parses into a new component.  In the latter
422     * case, expects a BEGIN as the first line.  Returns the provided or newly
423     * created top-level component.
424     */
425    // TODO: use an index into the text, so we can make this a recursive
426    // function?
427    private static Component parseComponentImpl(Component component,
428                                                String text)
429            throws FormatException {
430        Component current = component;
431        ParserState state = new ParserState();
432        state.index = 0;
433
434        // split into lines
435        String[] lines = text.split("\n");
436
437        // each line is of the format:
438        // name *(";" param) ":" value
439        for (String line : lines) {
440            try {
441                current = parseLine(line, state, current);
442                // if the provided component was null, we will return the root
443                // NOTE: in this case, if the first line is not a BEGIN, a
444                // FormatException will get thrown.
445                if (component == null) {
446                    component = current;
447                }
448            } catch (FormatException fe) {
449                if (false) {
450                    Log.v(TAG, "Cannot parse " + line, fe);
451                }
452                // for now, we ignore the parse error.  Google Calendar seems
453                // to be emitting some misformatted iCalendar objects.
454            }
455            continue;
456        }
457        return component;
458    }
459
460    /**
461     * Parses a line into the provided component.  Creates a new component if
462     * the line is a BEGIN, adding the newly created component to the provided
463     * parent.  Returns whatever component is the current one (to which new
464     * properties will be added) in the parse.
465     */
466    private static Component parseLine(String line, ParserState state,
467                                       Component component)
468            throws FormatException {
469        state.line = line;
470        int len = state.line.length();
471
472        // grab the name
473        char c = 0;
474        for (state.index = 0; state.index < len; ++state.index) {
475            c = line.charAt(state.index);
476            if (c == ';' || c == ':') {
477                break;
478            }
479        }
480        String name = line.substring(0, state.index);
481
482        if (component == null) {
483            if (!Component.BEGIN.equals(name)) {
484                throw new FormatException("Expected BEGIN");
485            }
486        }
487
488        Property property;
489        if (Component.BEGIN.equals(name)) {
490            // start a new component
491            String componentName = extractValue(state);
492            Component child = new Component(componentName, component);
493            if (component != null) {
494                component.addChild(child);
495            }
496            return child;
497        } else if (Component.END.equals(name)) {
498            // finish the current component
499            String componentName = extractValue(state);
500            if (component == null ||
501                    !componentName.equals(component.getName())) {
502                throw new FormatException("Unexpected END " + componentName);
503            }
504            return component.getParent();
505        } else {
506            property = new Property(name);
507        }
508
509        if (c == ';') {
510            Parameter parameter = null;
511            while ((parameter = extractParameter(state)) != null) {
512                property.addParameter(parameter);
513            }
514        }
515        String value = extractValue(state);
516        property.setValue(value);
517        component.addProperty(property);
518        return component;
519    }
520
521    /**
522     * Extracts the value ":..." on the current line.  The first character must
523     * be a ':'.
524     */
525    private static String extractValue(ParserState state)
526            throws FormatException {
527        String line = state.line;
528        if (state.index >= line.length() || line.charAt(state.index) != ':') {
529            throw new FormatException("Expected ':' before end of line in "
530                    + line);
531        }
532        String value = line.substring(state.index + 1);
533        state.index = line.length() - 1;
534        return value;
535    }
536
537    /**
538     * Extracts the next parameter from the line, if any.  If there are no more
539     * parameters, returns null.
540     */
541    private static Parameter extractParameter(ParserState state)
542            throws FormatException {
543        String text = state.line;
544        int len = text.length();
545        Parameter parameter = null;
546        int startIndex = -1;
547        int equalIndex = -1;
548        while (state.index < len) {
549            char c = text.charAt(state.index);
550            if (c == ':') {
551                if (parameter != null) {
552                    if (equalIndex == -1) {
553                        throw new FormatException("Expected '=' within "
554                                + "parameter in " + text);
555                    }
556                    parameter.value = text.substring(equalIndex + 1,
557                                                     state.index);
558                }
559                return parameter; // may be null
560            } else if (c == ';') {
561                if (parameter != null) {
562                    if (equalIndex == -1) {
563                        throw new FormatException("Expected '=' within "
564                                + "parameter in " + text);
565                    }
566                    parameter.value = text.substring(equalIndex + 1,
567                                                     state.index);
568                    return parameter;
569                } else {
570                    parameter = new Parameter();
571                    startIndex = state.index;
572                }
573            } else if (c == '=') {
574                equalIndex = state.index;
575                if ((parameter == null) || (startIndex == -1)) {
576                    throw new FormatException("Expected ';' before '=' in "
577                            + text);
578                }
579                parameter.name = text.substring(startIndex + 1, equalIndex);
580            } else if (c == '"') {
581                if (parameter == null) {
582                    throw new FormatException("Expected parameter before '\"' in " + text);
583                }
584                if (equalIndex == -1) {
585                    throw new FormatException("Expected '=' within parameter in " + text);
586                }
587                if (state.index > equalIndex + 1) {
588                    throw new FormatException("Parameter value cannot contain a '\"' in " + text);
589                }
590                final int endQuote = text.indexOf('"', state.index + 1);
591                if (endQuote < 0) {
592                    throw new FormatException("Expected closing '\"' in " + text);
593                }
594                parameter.value = text.substring(state.index + 1, endQuote);
595                state.index = endQuote + 1;
596                return parameter;
597            }
598            ++state.index;
599        }
600        throw new FormatException("Expected ':' before end of line in " + text);
601    }
602
603    /**
604     * Parses the provided text into an iCalendar object.  The top-level
605     * component must be of type VCALENDAR.
606     * @param text The text to be parsed.
607     * @return The top-level VCALENDAR component.
608     * @throws FormatException Thrown if the text could not be parsed into an
609     * iCalendar VCALENDAR object.
610     */
611    public static Component parseCalendar(String text) throws FormatException {
612        Component calendar = parseComponent(null, text);
613        if (calendar == null || !Component.VCALENDAR.equals(calendar.getName())) {
614            throw new FormatException("Expected " + Component.VCALENDAR);
615        }
616        return calendar;
617    }
618
619    /**
620     * Parses the provided text into an iCalendar event.  The top-level
621     * component must be of type VEVENT.
622     * @param text The text to be parsed.
623     * @return The top-level VEVENT component.
624     * @throws FormatException Thrown if the text could not be parsed into an
625     * iCalendar VEVENT.
626     */
627    public static Component parseEvent(String text) throws FormatException {
628        Component event = parseComponent(null, text);
629        if (event == null || !Component.VEVENT.equals(event.getName())) {
630            throw new FormatException("Expected " + Component.VEVENT);
631        }
632        return event;
633    }
634
635    /**
636     * Parses the provided text into an iCalendar component.
637     * @param text The text to be parsed.
638     * @return The top-level component.
639     * @throws FormatException Thrown if the text could not be parsed into an
640     * iCalendar component.
641     */
642    public static Component parseComponent(String text) throws FormatException {
643        return parseComponent(null, text);
644    }
645
646    /**
647     * Parses the provided text, adding to the provided component.
648     * @param component The component to which the parsed iCalendar data should
649     * be added.
650     * @param text The text to be parsed.
651     * @return The top-level component.
652     * @throws FormatException Thrown if the text could not be parsed as an
653     * iCalendar object.
654     */
655    public static Component parseComponent(Component component, String text)
656        throws FormatException {
657        text = normalizeText(text);
658        return parseComponentImpl(component, text);
659    }
660}
661