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 a plain-text iCalendar data stream.
072 * </p>
073 * <p>
074 * <b>Example:</b>
075 * 
076 * <pre class="brush:java">
077 * File file = new File("icals.ics");
078 * ICalReader reader = null;
079 * try {
080 *   reader = new ICalReader(file);
081 *   ICalendar ical;
082 *   while ((ical = reader.readNext()) != null){
083 *     ...
084 *   }
085 * } finally {
086 *   if (reader != null) reader.close();
087 * }
088 * </pre>
089 * 
090 * </p>
091 * 
092 * <p>
093 * <b>Getting timezone information:</b>
094 * 
095 * <pre class="brush:java">
096 * ICalReader reader = ...
097 * ICalendar ical = reader.readNext();
098 * TimezoneInfo tzinfo = reader.getTimezoneInfo();
099 * 
100 * //get the VTIMEZONE components that were parsed
101 * //the VTIMEZONE components will NOT be in the ICalendar object
102 * Collection&ltVTimezone&gt; vtimezones = tzinfo.getComponents();
103 * 
104 * //get the timezone that a property was originally formatted in
105 * DateStart dtstart = ical.getEvents().get(0).getDateStart();
106 * TimeZone tz = tzinfo.getTimeZone(dtstart);
107 * </pre>
108 * 
109 * </p>
110 * @author Michael Angstadt
111 * @see <a href="http://www.imc.org/pdi/pdiproddev.html">1.0 specs</a>
112 * @see <a href="https://tools.ietf.org/html/rfc2445">RFC 2445</a>
113 * @see <a href="http://tools.ietf.org/html/rfc5545">RFC 5545</a>
114 */
115public class ICalReader extends StreamReader {
116        private static final String icalComponentName = ScribeIndex.getICalendarScribe().getComponentName();
117
118        private final ICalRawReader reader;
119        private Charset defaultQuotedPrintableCharset;
120
121        /**
122         * @param str the string to read from
123         */
124        public ICalReader(String str) {
125                this(new StringReader(str));
126        }
127
128        /**
129         * @param in the input stream to read from
130         */
131        public ICalReader(InputStream in) {
132                this(utf8Reader(in));
133        }
134
135        /**
136         * @param file the file to read from
137         * @throws FileNotFoundException if the file doesn't exist
138         */
139        public ICalReader(File file) throws FileNotFoundException {
140                this(utf8Reader(file));
141        }
142
143        /**
144         * @param reader the reader to read from
145         */
146        public ICalReader(Reader reader) {
147                this.reader = new ICalRawReader(reader);
148                defaultQuotedPrintableCharset = this.reader.getEncoding();
149                if (defaultQuotedPrintableCharset == null) {
150                        defaultQuotedPrintableCharset = Charset.defaultCharset();
151                }
152        }
153
154        /**
155         * Gets whether the reader will decode parameter values that use circumflex
156         * accent encoding (enabled by default). This escaping mechanism allows
157         * newlines and double quotes to be included in parameter values.
158         * @return true if circumflex accent decoding is enabled, false if not
159         * @see ICalRawReader#isCaretDecodingEnabled()
160         */
161        public boolean isCaretDecodingEnabled() {
162                return reader.isCaretDecodingEnabled();
163        }
164
165        /**
166         * Sets whether the reader will decode parameter values that use circumflex
167         * accent encoding (enabled by default). This escaping mechanism allows
168         * newlines and double quotes to be included in parameter values.
169         * @param enable true to use circumflex accent decoding, false not to
170         * @see ICalRawReader#setCaretDecodingEnabled(boolean)
171         */
172        public void setCaretDecodingEnabled(boolean enable) {
173                reader.setCaretDecodingEnabled(enable);
174        }
175
176        /**
177         * <p>
178         * Gets the character set to use when decoding quoted-printable values if
179         * the property has no CHARSET parameter, or if the CHARSET parameter is not
180         * a valid character set.
181         * </p>
182         * <p>
183         * By default, the Reader's character encoding will be used. If the Reader
184         * has no character encoding, then the system's default character encoding
185         * will be used.
186         * </p>
187         * @return the character set
188         */
189        public Charset getDefaultQuotedPrintableCharset() {
190                return defaultQuotedPrintableCharset;
191        }
192
193        /**
194         * <p>
195         * Sets the character set to use when decoding quoted-printable values if
196         * the property has no CHARSET parameter, or if the CHARSET parameter is not
197         * a valid character set.
198         * </p>
199         * <p>
200         * By default, the Reader's character encoding will be used. If the Reader
201         * has no character encoding, then the system's default character encoding
202         * will be used.
203         * </p>
204         * @param charset the character set
205         */
206        public void setDefaultQuotedPrintableCharset(Charset charset) {
207                defaultQuotedPrintableCharset = charset;
208        }
209
210        @Override
211        protected ICalendar _readNext() throws IOException {
212                ICalendar ical = null;
213                List<String> values = new ArrayList<String>();
214                ComponentStack stack = new ComponentStack();
215
216                while (true) {
217                        //read next line
218                        ICalRawLine line;
219                        try {
220                                line = reader.readLine();
221                        } catch (ICalParseException e) {
222                                warnings.add(reader.getLineNum(), null, 3, e.getLine());
223                                continue;
224                        }
225
226                        //EOF
227                        if (line == null) {
228                                break;
229                        }
230
231                        context.setVersion(reader.getVersion());
232                        String propertyName = line.getName();
233
234                        if ("BEGIN".equalsIgnoreCase(propertyName)) {
235                                String componentName = line.getValue();
236                                if (ical == null && !icalComponentName.equalsIgnoreCase(componentName)) {
237                                        //keep reading until a VCALENDAR component is found
238                                        continue;
239                                }
240
241                                ICalComponent parentComponent = stack.peek();
242
243                                ICalComponentScribe<? extends ICalComponent> scribe = index.getComponentScribe(componentName, reader.getVersion());
244                                ICalComponent component = scribe.emptyInstance();
245                                stack.push(component, componentName);
246
247                                if (parentComponent == null) {
248                                        ical = (ICalendar) component;
249                                } else {
250                                        parentComponent.addComponent(component);
251                                }
252
253                                continue;
254                        }
255
256                        if (ical == null) {
257                                //VCALENDAR component hasn't been found yet
258                                continue;
259                        }
260
261                        if ("END".equalsIgnoreCase(propertyName)) {
262                                String componentName = line.getValue();
263
264                                //stop reading when "END:VCALENDAR" is reached
265                                if (icalComponentName.equalsIgnoreCase(componentName)) {
266                                        break;
267                                }
268
269                                //find the component that this END property matches up with
270                                boolean found = stack.popThrough(componentName);
271                                if (!found) {
272                                        //END property does not match up with any BEGIN properties, so ignore
273                                        warnings.add(reader.getLineNum(), "END", 2);
274                                }
275
276                                continue;
277                        }
278
279                        ICalParameters parameters = line.getParameters();
280                        String value = line.getValue();
281
282                        ICalPropertyScribe<? extends ICalProperty> scribe = index.getPropertyScribe(propertyName, reader.getVersion());
283
284                        //process nameless parameters
285                        processNamelessParameters(parameters, propertyName);
286
287                        //decode property value from quoted-printable
288                        if (parameters.getEncoding() == Encoding.QUOTED_PRINTABLE) {
289                                try {
290                                        value = decodeQuotedPrintableValue(propertyName, parameters.getCharset(), value);
291                                } catch (DecoderException e) {
292                                        warnings.add(reader.getLineNum(), propertyName, 31, e.getMessage());
293                                }
294                                parameters.setEncoding(null);
295                        }
296
297                        //get the data type (VALUE parameter)
298                        ICalDataType dataType = parameters.getValue();
299                        if (dataType == null) {
300                                //use the default data type if there is no VALUE parameter
301                                dataType = scribe.defaultDataType(reader.getVersion());
302                        } else {
303                                //remove VALUE parameter if it is set
304                                parameters.setValue(null);
305                        }
306
307                        //determine how many properties should be parsed from this property value
308                        values.clear();
309                        if (reader.getVersion() == ICalVersion.V1_0 && scribe instanceof RecurrencePropertyScribe) {
310                                //extract each RRULE from the value string (there can be multiple)
311                                Pattern p = Pattern.compile("#\\d+|\\d{8}T\\d{6}Z?");
312                                Matcher m = p.matcher(value);
313
314                                int prevIndex = 0;
315                                while (m.find()) {
316                                        int end = m.end() + 1;
317                                        String subValue = value.substring(prevIndex, end).trim();
318                                        values.add(subValue);
319                                        prevIndex = end;
320                                }
321                                String subValue = value.substring(prevIndex).trim();
322                                if (subValue.length() > 0) {
323                                        values.add(subValue);
324                                }
325                        } else {
326                                values.add(value);
327                        }
328
329                        context.getWarnings().clear();
330                        List<ICalProperty> propertiesToAdd = new ArrayList<ICalProperty>();
331                        List<Result<? extends ICalComponent>> componentsToAdd = new ArrayList<Result<? extends ICalComponent>>();
332                        for (String v : values) {
333                                try {
334                                        ICalProperty property = scribe.parseText(v, dataType, parameters, context);
335                                        propertiesToAdd.add(property);
336                                } catch (SkipMeException e) {
337                                        warnings.add(reader.getLineNum(), propertyName, 0, e.getMessage());
338                                        continue;
339                                } catch (CannotParseException e) {
340                                        warnings.add(reader.getLineNum(), propertyName, 1, v, e.getMessage());
341
342                                        ICalProperty property = new RawPropertyScribe(propertyName).parseText(v, dataType, parameters, context);
343                                        propertiesToAdd.add(property);
344                                }
345                        }
346
347                        //add the properties to the iCalendar object
348                        ICalComponent parentComponent = stack.peek();
349                        boolean isVCal = reader.getVersion() == null || reader.getVersion() == ICalVersion.V1_0;
350                        for (ICalProperty property : propertiesToAdd) {
351                                for (Warning warning : context.getWarnings()) {
352                                        warnings.add(reader.getLineNum(), propertyName, warning);
353                                }
354
355                                if (isVCal) {
356                                        Object obj = convertVCalProperty(property);
357                                        if (obj instanceof ICalComponent) {
358                                                parentComponent.addComponent((ICalComponent) obj);
359                                                continue;
360                                        }
361                                        if (obj instanceof ICalProperty) {
362                                                property = (ICalProperty) obj;
363                                        }
364                                }
365
366                                parentComponent.addProperty(property);
367                        }
368
369                        //add the components to the iCalendar object
370                        for (Result<? extends ICalComponent> result : componentsToAdd) {
371                                for (Warning warning : result.getWarnings()) {
372                                        warnings.add(reader.getLineNum(), propertyName, warning);
373                                }
374
375                                parentComponent.addComponent(result.getProperty());
376                        }
377                }
378
379                return ical;
380        }
381
382        /**
383         * Assigns names to all nameless parameters. v2.0 requires all parameters to
384         * have names, but v1.0 does not.
385         * @param parameters the parameters
386         * @param propertyName the property name
387         */
388        private void processNamelessParameters(ICalParameters parameters, String propertyName) {
389                List<String> namelessParamValues = parameters.removeAll(null);
390                if (namelessParamValues.isEmpty()) {
391                        return;
392                }
393
394                if (reader.getVersion() != ICalVersion.V1_0) {
395                        warnings.add(reader.getLineNum(), propertyName, 4, namelessParamValues);
396                }
397
398                for (String paramValue : namelessParamValues) {
399                        String paramName = guessParameterName(paramValue);
400                        parameters.put(paramName, paramValue);
401                }
402        }
403
404        /**
405         * Makes a guess as to what a parameter value's name should be.
406         * @param value the parameter value
407         * @return the guessed name
408         */
409        private String guessParameterName(String value) {
410                if (ICalDataType.find(value) != null) {
411                        return ICalParameters.VALUE;
412                }
413
414                if (Encoding.find(value) != null) {
415                        return ICalParameters.ENCODING;
416                }
417
418                //otherwise, assume it's a TYPE
419                return ICalParameters.TYPE;
420        }
421
422        /**
423         * Decodes the property value if it's encoded in quoted-printable encoding.
424         * Quoted-printable encoding is only supported in v1.0.
425         * @param propertyName the property name
426         * @param charsetParam the value of the CHARSET parameter
427         * @param value the property value
428         * @return the decoded property value
429         * @throws DecoderException if the value couldn't be decoded
430         */
431        private String decodeQuotedPrintableValue(String propertyName, String charsetParam, String value) throws DecoderException {
432                //determine the character set
433                Charset charset = null;
434                if (charsetParam == null) {
435                        charset = defaultQuotedPrintableCharset;
436                } else {
437                        try {
438                                charset = Charset.forName(charsetParam);
439                        } catch (Throwable t) {
440                                charset = defaultQuotedPrintableCharset;
441
442                                //the given charset was invalid, so add a warning
443                                warnings.add(reader.getLineNum(), propertyName, 32, charsetParam, charset.name());
444                        }
445                }
446
447                QuotedPrintableCodec codec = new QuotedPrintableCodec(charset.name());
448                return codec.decode(value);
449        }
450
451        /**
452         * Converts a vCal property to the iCalendar data model.
453         * @param property the vCal property
454         * @return the converted iCalendar property/component, or the same property
455         * that was passed in if no conversion was necessary
456         */
457        private Object convertVCalProperty(ICalProperty property) {
458                //ATTENDEE with "organizer" role => ORGANIZER property
459                if (property instanceof Attendee) {
460                        Attendee attendee = (Attendee) property;
461                        return (attendee.getRole() == Role.ORGANIZER) ? convert(attendee) : property;
462                }
463
464                //AALARM property => VALARM component
465                if (property instanceof AudioAlarm) {
466                        AudioAlarm aalarm = (AudioAlarm) property;
467                        return convert(aalarm);
468                }
469
470                //DALARM property => VALARM component
471                if (property instanceof DisplayAlarm) {
472                        DisplayAlarm dalarm = (DisplayAlarm) property;
473                        return convert(dalarm);
474                }
475
476                //MALARM property => VALARM component
477                if (property instanceof EmailAlarm) {
478                        EmailAlarm malarm = (EmailAlarm) property;
479                        return convert(malarm);
480                }
481
482                //PALARM property => VALARM component
483                if (property instanceof ProcedureAlarm) {
484                        ProcedureAlarm palarm = (ProcedureAlarm) property;
485                        return convert(palarm);
486                }
487
488                return property;
489        }
490
491        /**
492         * Closes the underlying {@link Reader} object.
493         */
494        public void close() throws IOException {
495                reader.close();
496        }
497
498        private static class ComponentStack {
499                private final List<ICalComponent> components = new ArrayList<ICalComponent>();
500                private final List<String> names = new ArrayList<String>();
501
502                /**
503                 * Gets the component on the top of the stack.
504                 * @return the component or null if the stack is empty
505                 */
506                public ICalComponent peek() {
507                        return components.isEmpty() ? null : components.get(components.size() - 1);
508                }
509
510                /**
511                 * Adds a component to the stack
512                 * @param component the component
513                 * @param name the component's name (e.g. "VEVENT")
514                 */
515                public void push(ICalComponent component, String name) {
516                        components.add(component);
517                        names.add(name);
518                }
519
520                /**
521                 * Removes all components that come after the given component, including
522                 * the given component itself.
523                 * @param name the component's name (e.g. "VEVENT")
524                 * @return true if the component was found, false if not
525                 */
526                public boolean popThrough(String name) {
527                        for (int i = components.size() - 1; i >= 0; i--) {
528                                String curName = names.get(i);
529                                if (curName.equalsIgnoreCase(name)) {
530                                        components.subList(i, components.size()).clear();
531                                        names.subList(i, names.size()).clear();
532                                        return true;
533                                }
534                        }
535
536                        return false;
537                }
538        }
539}