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<VTimezone> 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}