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 17 package com.android.calendarcommon2; 18 19 import android.util.Log; 20 21 import java.util.LinkedHashMap; 22 import java.util.LinkedList; 23 import java.util.List; 24 import java.util.Set; 25 import java.util.ArrayList; 26 27 /** 28 * Parses RFC 2445 iCalendar objects. 29 */ 30 public 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