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