001package biweekly.io.text;
002
003import static biweekly.io.DataModelConverter.convert;
004import static biweekly.util.IOUtils.utf8Reader;
005
006import java.io.File;
007import java.io.FileNotFoundException;
008import java.io.IOException;
009import java.io.InputStream;
010import java.io.Reader;
011import java.io.StringReader;
012import java.nio.charset.Charset;
013import java.util.ArrayList;
014import java.util.List;
015import java.util.regex.Matcher;
016import java.util.regex.Pattern;
017
018import biweekly.ICalDataType;
019import biweekly.ICalVersion;
020import biweekly.ICalendar;
021import biweekly.Warning;
022import biweekly.component.ICalComponent;
023import biweekly.io.CannotParseException;
024import biweekly.io.SkipMeException;
025import biweekly.io.StreamReader;
026import biweekly.io.scribe.ScribeIndex;
027import biweekly.io.scribe.component.ICalComponentScribe;
028import biweekly.io.scribe.property.ICalPropertyScribe;
029import biweekly.io.scribe.property.ICalPropertyScribe.Result;
030import biweekly.io.scribe.property.RawPropertyScribe;
031import biweekly.io.scribe.property.RecurrencePropertyScribe;
032import biweekly.parameter.Encoding;
033import biweekly.parameter.ICalParameters;
034import biweekly.parameter.Role;
035import biweekly.property.Attendee;
036import biweekly.property.AudioAlarm;
037import biweekly.property.DisplayAlarm;
038import biweekly.property.EmailAlarm;
039import biweekly.property.ICalProperty;
040import biweekly.property.ProcedureAlarm;
041import biweekly.util.org.apache.commons.codec.DecoderException;
042import biweekly.util.org.apache.commons.codec.net.QuotedPrintableCodec;
043
044/*
045 Copyright (c) 2013-2015, Michael Angstadt
046 All rights reserved.
047
048 Redistribution and use in source and binary forms, with or without
049 modification, are permitted provided that the following conditions are met: 
050
051 1. Redistributions of source code must retain the above copyright notice, this
052 list of conditions and the following disclaimer. 
053 2. Redistributions in binary form must reproduce the above copyright notice,
054 this list of conditions and the following disclaimer in the documentation
055 and/or other materials provided with the distribution. 
056
057 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
058 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
059 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
060 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
061 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
062 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
063 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
064 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
065 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
066 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
067 */
068
069/**
070 * <p>
071 * Parses {@link ICalendar} objects from an iCalendar data stream.
072 * </p>
073 * <p>
074 * <b>Example:</b>
075 * 
076 * <pre class="brush:java">
077 * InputStream in = ...
078 * ICalReader icalReader = new ICalReader(in);
079 * ICalendar ical;
080 * while ((ical = icalReader.readNext()) != null){
081 *   ...
082 * }
083 * icalReader.close();
084 * </pre>
085 * 
086 * </p>
087 * 
088 * <p>
089 * <b>Getting timezone information:</b>
090 * 
091 * <pre class="brush:java">
092 * ICalReader reader = ...
093 * ICalendar ical = reader.readNext();
094 * TimezoneInfo tzinfo = reader.getTimezoneInfo();
095 * 
096 * //get the VTIMEZONE components that were parsed
097 * //the VTIMEZONE components will NOT be in the ICalendar object
098 * Collection&ltVTimezone&gt; vtimezones = tzinfo.getComponents();
099 * 
100 * //get the timezone that a property was originally formatted in
101 * DateStart dtstart = ical.getEvents().get(0).getDateStart();
102 * TimeZone tz = tzinfo.getTimeZone(dtstart);
103 * </pre>
104 * 
105 * </p>
106 * @author Michael Angstadt
107 * @see <a href="http://tools.ietf.org/html/rfc5545">RFC 5545</a>
108 */
109public class ICalReader extends StreamReader {
110        private static final String icalComponentName = ScribeIndex.getICalendarScribe().getComponentName();
111
112        private final ICalRawReader reader;
113        private Charset defaultQuotedPrintableCharset;
114
115        /**
116         * Creates a reader that parses iCalendar objects from a string.
117         * @param string the string
118         */
119        public ICalReader(String string) {
120                this(new StringReader(string));
121        }
122
123        /**
124         * Creates a reader that parses iCalendar objects from an input stream.
125         * @param in the input stream
126         */
127        public ICalReader(InputStream in) {
128                this(utf8Reader(in));
129        }
130
131        /**
132         * Creates a reader that parses iCalendar objects from a file.
133         * @param file the file
134         * @throws FileNotFoundException if the file doesn't exist
135         */
136        public ICalReader(File file) throws FileNotFoundException {
137                this(utf8Reader(file));
138        }
139
140        /**
141         * Creates a reader that parses iCalendar objects from a reader.
142         * @param reader the reader
143         */
144        public ICalReader(Reader reader) {
145                this.reader = new ICalRawReader(reader);
146                defaultQuotedPrintableCharset = this.reader.getEncoding();
147                if (defaultQuotedPrintableCharset == null) {
148                        defaultQuotedPrintableCharset = Charset.defaultCharset();
149                }
150        }
151
152        /**
153         * Gets whether the reader will decode parameter values that use circumflex
154         * accent encoding (enabled by default). This escaping mechanism allows
155         * newlines and double quotes to be included in parameter values.
156         * @return true if circumflex accent decoding is enabled, false if not
157         * @see ICalRawReader#isCaretDecodingEnabled()
158         */
159        public boolean isCaretDecodingEnabled() {
160                return reader.isCaretDecodingEnabled();
161        }
162
163        /**
164         * Sets whether the reader will decode parameter values that use circumflex
165         * accent encoding (enabled by default). This escaping mechanism allows
166         * newlines and double quotes to be included in parameter values.
167         * @param enable true to use circumflex accent decoding, false not to
168         * @see ICalRawReader#setCaretDecodingEnabled(boolean)
169         */
170        public void setCaretDecodingEnabled(boolean enable) {
171                reader.setCaretDecodingEnabled(enable);
172        }
173
174        /**
175         * <p>
176         * Gets the character set to use when decoding quoted-printable values if
177         * the property has no CHARSET parameter, or if the CHARSET parameter is not
178         * a valid character set.
179         * </p>
180         * <p>
181         * By default, the Reader's character encoding will be used. If the Reader
182         * has no character encoding, then the system's default character encoding
183         * will be used.
184         * </p>
185         * @return the character set
186         */
187        public Charset getDefaultQuotedPrintableCharset() {
188                return defaultQuotedPrintableCharset;
189        }
190
191        /**
192         * <p>
193         * Sets the character set to use when decoding quoted-printable values if
194         * the property has no CHARSET parameter, or if the CHARSET parameter is not
195         * a valid character set.
196         * </p>
197         * <p>
198         * By default, the Reader's character encoding will be used. If the Reader
199         * has no character encoding, then the system's default character encoding
200         * will be used.
201         * </p>
202         * @param charset the character set
203         */
204        public void setDefaultQuotedPrintableCharset(Charset charset) {
205                defaultQuotedPrintableCharset = charset;
206        }
207
208        @Override
209        protected ICalendar _readNext() throws IOException {
210                ICalendar ical = null;
211                List<String> values = new ArrayList<String>();
212                ComponentStack stack = new ComponentStack();
213
214                while (true) {
215                        //read next line
216                        ICalRawLine line;
217                        try {
218                                line = reader.readLine();
219                        } catch (ICalParseException e) {
220                                warnings.add(reader.getLineNum(), null, 3, e.getLine());
221                                continue;
222                        }
223
224                        //EOF
225                        if (line == null) {
226                                break;
227                        }
228
229                        context.setVersion(reader.getVersion());
230                        String propertyName = line.getName();
231
232                        if ("BEGIN".equalsIgnoreCase(propertyName)) {
233                                String componentName = line.getValue();
234                                if (ical == null && !icalComponentName.equalsIgnoreCase(componentName)) {
235                                        //keep reading until a VCALENDAR component is found
236                                        continue;
237                                }
238
239                                ICalComponent parentComponent = stack.peek();
240
241                                ICalComponentScribe<? extends ICalComponent> scribe = index.getComponentScribe(componentName, reader.getVersion());
242                                ICalComponent component = scribe.emptyInstance();
243                                stack.push(component, componentName);
244
245                                if (parentComponent == null) {
246                                        ical = (ICalendar) component;
247                                } else {
248                                        parentComponent.addComponent(component);
249                                }
250
251                                continue;
252                        }
253
254                        if (ical == null) {
255                                //VCALENDAR component hasn't been found yet
256                                continue;
257                        }
258
259                        if ("END".equalsIgnoreCase(propertyName)) {
260                                String componentName = line.getValue();
261
262                                //stop reading when "END:VCALENDAR" is reached
263                                if (icalComponentName.equalsIgnoreCase(componentName)) {
264                                        break;
265                                }
266
267                                //find the component that this END property matches up with
268                                boolean found = stack.popThrough(componentName);
269                                if (!found) {
270                                        //END property does not match up with any BEGIN properties, so ignore
271                                        warnings.add(reader.getLineNum(), "END", 2);
272                                }
273
274                                continue;
275                        }
276
277                        ICalParameters parameters = line.getParameters();
278                        String value = line.getValue();
279
280                        ICalPropertyScribe<? extends ICalProperty> scribe = index.getPropertyScribe(propertyName, reader.getVersion());
281
282                        //process nameless parameters
283                        processNamelessParameters(parameters, propertyName);
284
285                        //decode property value from quoted-printable
286                        if (parameters.getEncoding() == Encoding.QUOTED_PRINTABLE) {
287                                try {
288                                        value = decodeQuotedPrintable(propertyName, parameters.getCharset(), value);
289                                } catch (DecoderException e) {
290                                        warnings.add(reader.getLineNum(), propertyName, 31, e.getMessage());
291                                }
292                                parameters.setEncoding(null);
293                        }
294
295                        //get the data type
296                        ICalDataType dataType = parameters.getValue();
297                        if (dataType == null) {
298                                //use the default data type if there is no VALUE parameter
299                                dataType = scribe.defaultDataType(reader.getVersion());
300                        } else {
301                                //remove VALUE parameter if it is set
302                                parameters.setValue(null);
303                        }
304
305                        //determine how many properties should be parsed from this property value
306                        values.clear();
307                        if (reader.getVersion() == ICalVersion.V1_0 && scribe instanceof RecurrencePropertyScribe) {
308                                //extract each RRULE from the value string (there can be multiple)
309                                Pattern p = Pattern.compile("#\\d+|\\d{8}T\\d{6}Z?");
310                                Matcher m = p.matcher(value);
311
312                                int prevIndex = 0;
313                                while (m.find()) {
314                                        int end = m.end() + 1;
315                                        String subValue = value.substring(prevIndex, end).trim();
316                                        values.add(subValue);
317                                        prevIndex = end;
318                                }
319                                String subValue = value.substring(prevIndex).trim();
320                                if (subValue.length() > 0) {
321                                        values.add(subValue);
322                                }
323                        } else {
324                                values.add(value);
325                        }
326
327                        context.getWarnings().clear();
328                        List<ICalProperty> propertiesToAdd = new ArrayList<ICalProperty>();
329                        List<Result<? extends ICalComponent>> componentsToAdd = new ArrayList<Result<? extends ICalComponent>>();
330                        for (String v : values) {
331                                try {
332                                        ICalProperty property = scribe.parseText(v, dataType, parameters, context);
333                                        propertiesToAdd.add(property);
334                                } catch (SkipMeException e) {
335                                        warnings.add(reader.getLineNum(), propertyName, 0, e.getMessage());
336                                        continue;
337                                } catch (CannotParseException e) {
338                                        warnings.add(reader.getLineNum(), propertyName, 1, v, e.getMessage());
339
340                                        ICalProperty property = new RawPropertyScribe(propertyName).parseText(v, dataType, parameters, context);
341                                        propertiesToAdd.add(property);
342                                }
343                        }
344
345                        //add the properties to the iCalendar object
346                        ICalComponent parentComponent = stack.peek();
347                        boolean isVCal = reader.getVersion() == null || reader.getVersion() == ICalVersion.V1_0;
348                        for (ICalProperty property : propertiesToAdd) {
349                                for (Warning warning : context.getWarnings()) {
350                                        warnings.add(reader.getLineNum(), propertyName, warning);
351                                }
352
353                                if (isVCal) {
354                                        Object obj = convertVCalProperty(property);
355                                        if (obj instanceof ICalComponent) {
356                                                parentComponent.addComponent((ICalComponent) obj);
357                                                continue;
358                                        }
359                                        if (obj instanceof ICalProperty) {
360                                                property = (ICalProperty) obj;
361                                        }
362                                }
363
364                                parentComponent.addProperty(property);
365                        }
366
367                        //add the components to the iCalendar object
368                        for (Result<? extends ICalComponent> result : componentsToAdd) {
369                                for (Warning warning : result.getWarnings()) {
370                                        warnings.add(reader.getLineNum(), propertyName, warning);
371                                }
372
373                                parentComponent.addComponent(result.getProperty());
374                        }
375                }
376
377                return ical;
378        }
379
380        /**
381         * Assigns names to all nameless parameters. v2.0 requires all parameters to
382         * have names, but v1.0 does not.
383         * @param parameters the parameters
384         * @param propertyName the property name
385         */
386        private void processNamelessParameters(ICalParameters parameters, String propertyName) {
387                List<String> namelessParamValues = parameters.get(null);
388                if (namelessParamValues.isEmpty()) {
389                        return;
390                }
391
392                if (reader.getVersion() != ICalVersion.V1_0) {
393                        warnings.add(reader.getLineNum(), propertyName, 4, namelessParamValues);
394                }
395
396                for (String paramValue : namelessParamValues) {
397                        String paramName;
398                        if (ICalDataType.find(paramValue) != null) {
399                                paramName = ICalParameters.VALUE;
400                        } else if (Encoding.find(paramValue) != null) {
401                                paramName = ICalParameters.ENCODING;
402                        } else {
403                                //otherwise, assume it's a TYPE
404                                paramName = ICalParameters.TYPE;
405                        }
406                        parameters.put(paramName, paramValue);
407                }
408                parameters.removeAll(null);
409        }
410
411        /**
412         * Decodes the property value if it's encoded in quoted-printable encoding.
413         * Quoted-printable encoding is only supported in v1.0.
414         * @param propertyName the property name
415         * @param charsetParam the value of the CHARSET parameter
416         * @param value the property value
417         * @return the decoded property value
418         * @throws DecoderException if the value couldn't be decoded
419         */
420        private String decodeQuotedPrintable(String propertyName, String charsetParam, String value) throws DecoderException {
421                //determine the character set
422                Charset charset = null;
423                if (charsetParam == null) {
424                        charset = defaultQuotedPrintableCharset;
425                } else {
426                        try {
427                                charset = Charset.forName(charsetParam);
428                        } catch (Throwable t) {
429                                charset = defaultQuotedPrintableCharset;
430
431                                //the given charset was invalid, so add a warning
432                                warnings.add(reader.getLineNum(), propertyName, 32, charsetParam, charset.name());
433                        }
434                }
435
436                QuotedPrintableCodec codec = new QuotedPrintableCodec(charset.name());
437                return codec.decode(value);
438        }
439
440        /**
441         * Converts a vCal property to the iCalendar data model.
442         * @param property the vCal property
443         * @return the converted iCalendar property/component, or the given property
444         * if no conversion is necessary
445         */
446        private Object convertVCalProperty(ICalProperty property) {
447                //ATTENDEE with "organizer" role => ORGANIZER property
448                if (property instanceof Attendee) {
449                        Attendee attendee = (Attendee) property;
450                        return (attendee.getRole() == Role.ORGANIZER) ? convert(attendee) : property;
451                }
452
453                //AALARM property => VALARM component
454                if (property instanceof AudioAlarm) {
455                        AudioAlarm aalarm = (AudioAlarm) property;
456                        return convert(aalarm);
457                }
458
459                //DALARM property => VALARM component
460                if (property instanceof DisplayAlarm) {
461                        DisplayAlarm dalarm = (DisplayAlarm) property;
462                        return convert(dalarm);
463                }
464
465                //MALARM property => VALARM component
466                if (property instanceof EmailAlarm) {
467                        EmailAlarm malarm = (EmailAlarm) property;
468                        return convert(malarm);
469                }
470
471                //PALARM property => VALARM component
472                if (property instanceof ProcedureAlarm) {
473                        ProcedureAlarm palarm = (ProcedureAlarm) property;
474                        return convert(palarm);
475                }
476
477                return property;
478        }
479
480        /**
481         * Closes the underlying {@link Reader} object.
482         */
483        public void close() throws IOException {
484                reader.close();
485        }
486
487        private static class ComponentStack {
488                private final List<ICalComponent> components = new ArrayList<ICalComponent>();
489                private final List<String> names = new ArrayList<String>();
490
491                /**
492                 * Gets the component on the top of the stack.
493                 * @return the component or null if the stack is empty
494                 */
495                public ICalComponent peek() {
496                        return components.isEmpty() ? null : components.get(components.size() - 1);
497                }
498
499                /**
500                 * Adds a component to the stack
501                 * @param component the component
502                 * @param name the component's name (e.g. "VEVENT")
503                 */
504                public void push(ICalComponent component, String name) {
505                        components.add(component);
506                        names.add(name);
507                }
508
509                /**
510                 * Removes all components that come after the given component, including
511                 * the given component itself.
512                 * @param name the component's name (e.g. "VEVENT")
513                 * @return true if the component was found, false if not
514                 */
515                public boolean popThrough(String name) {
516                        for (int i = components.size() - 1; i >= 0; i--) {
517                                String curName = names.get(i);
518                                if (curName.equalsIgnoreCase(name)) {
519                                        components.subList(i, components.size()).clear();
520                                        names.subList(i, names.size()).clear();
521                                        return true;
522                                }
523                        }
524
525                        return false;
526                }
527        }
528}