/*
* Copyright (C) 2007 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.calendarcommon2;
import android.util.Log;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.ArrayList;
/**
* Parses RFC 2445 iCalendar objects.
*/
public class ICalendar {
private static final String TAG = "Sync";
// TODO: keep track of VEVENT, VTODO, VJOURNAL, VFREEBUSY, VTIMEZONE, VALARM
// components, by type field or by subclass? subclass would allow us to
// enforce grammars.
/**
* Exception thrown when an iCalendar object has invalid syntax.
*/
public static class FormatException extends Exception {
public FormatException() {
super();
}
public FormatException(String msg) {
super(msg);
}
public FormatException(String msg, Throwable cause) {
super(msg, cause);
}
}
/**
* A component within an iCalendar (VEVENT, VTODO, VJOURNAL, VFEEBUSY,
* VTIMEZONE, VALARM).
*/
public static class Component {
// components
static final String BEGIN = "BEGIN";
static final String END = "END";
private static final String NEWLINE = "\n";
public static final String VCALENDAR = "VCALENDAR";
public static final String VEVENT = "VEVENT";
public static final String VTODO = "VTODO";
public static final String VJOURNAL = "VJOURNAL";
public static final String VFREEBUSY = "VFREEBUSY";
public static final String VTIMEZONE = "VTIMEZONE";
public static final String VALARM = "VALARM";
private final String mName;
private final Component mParent; // see if we can get rid of this
private LinkedList<Component> mChildren = null;
private final LinkedHashMap<String, ArrayList<Property>> mPropsMap =
new LinkedHashMap<String, ArrayList<Property>>();
/**
* Creates a new component with the provided name.
* @param name The name of the component.
*/
public Component(String name, Component parent) {
mName = name;
mParent = parent;
}
/**
* Returns the name of the component.
* @return The name of the component.
*/
public String getName() {
return mName;
}
/**
* Returns the parent of this component.
* @return The parent of this component.
*/
public Component getParent() {
return mParent;
}
/**
* Helper that lazily gets/creates the list of children.
* @return The list of children.
*/
protected LinkedList<Component> getOrCreateChildren() {
if (mChildren == null) {
mChildren = new LinkedList<Component>();
}
return mChildren;
}
/**
* Adds a child component to this component.
* @param child The child component.
*/
public void addChild(Component child) {
getOrCreateChildren().add(child);
}
/**
* Returns a list of the Component children of this component. May be
* null, if there are no children.
*
* @return A list of the children.
*/
public List<Component> getComponents() {
return mChildren;
}
/**
* Adds a Property to this component.
* @param prop
*/
public void addProperty(Property prop) {
String name= prop.getName();
ArrayList<Property> props = mPropsMap.get(name);
if (props == null) {
props = new ArrayList<Property>();
mPropsMap.put(name, props);
}
props.add(prop);
}
/**
* Returns a set of the property names within this component.
* @return A set of property names within this component.
*/
public Set<String> getPropertyNames() {
return mPropsMap.keySet();
}
/**
* Returns a list of properties with the specified name. Returns null
* if there are no such properties.
* @param name The name of the property that should be returned.
* @return A list of properties with the requested name.
*/
public List<Property> getProperties(String name) {
return mPropsMap.get(name);
}
/**
* Returns the first property with the specified name. Returns null
* if there is no such property.
* @param name The name of the property that should be returned.
* @return The first property with the specified name.
*/
public Property getFirstProperty(String name) {
List<Property> props = mPropsMap.get(name);
if (props == null || props.size() == 0) {
return null;
}
return props.get(0);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
toString(sb);
sb.append(NEWLINE);
return sb.toString();
}
/**
* Helper method that appends this component to a StringBuilder. The
* caller is responsible for appending a newline at the end of the
* component.
*/
public void toString(StringBuilder sb) {
sb.append(BEGIN);
sb.append(":");
sb.append(mName);
sb.append(NEWLINE);
// append the properties
for (String propertyName : getPropertyNames()) {
for (Property property : getProperties(propertyName)) {
property.toString(sb);
sb.append(NEWLINE);
}
}
// append the sub-components
if (mChildren != null) {
for (Component component : mChildren) {
component.toString(sb);
sb.append(NEWLINE);
}
}
sb.append(END);
sb.append(":");
sb.append(mName);
}
}
/**
* A property within an iCalendar component (e.g., DTSTART, DTEND, etc.,
* within a VEVENT).
*/
public static class Property {
// properties
// TODO: do we want to list these here? the complete list is long.
public static final String DTSTART = "DTSTART";
public static final String DTEND = "DTEND";
public static final String DURATION = "DURATION";
public static final String RRULE = "RRULE";
public static final String RDATE = "RDATE";
public static final String EXRULE = "EXRULE";
public static final String EXDATE = "EXDATE";
// ... need to add more.
private final String mName;
private LinkedHashMap<String, ArrayList<Parameter>> mParamsMap =
new LinkedHashMap<String, ArrayList<Parameter>>();
private String mValue; // TODO: make this final?
/**
* Creates a new property with the provided name.
* @param name The name of the property.
*/
public Property(String name) {
mName = name;
}
/**
* Creates a new property with the provided name and value.
* @param name The name of the property.
* @param value The value of the property.
*/
public Property(String name, String value) {
mName = name;
mValue = value;
}
/**
* Returns the name of the property.
* @return The name of the property.
*/
public String getName() {
return mName;
}
/**
* Returns the value of this property.
* @return The value of this property.
*/
public String getValue() {
return mValue;
}
/**
* Sets the value of this property.
* @param value The desired value for this property.
*/
public void setValue(String value) {
mValue = value;
}
/**
* Adds a {@link Parameter} to this property.
* @param param The parameter that should be added.
*/
public void addParameter(Parameter param) {
ArrayList<Parameter> params = mParamsMap.get(param.name);
if (params == null) {
params = new ArrayList<Parameter>();
mParamsMap.put(param.name, params);
}
params.add(param);
}
/**
* Returns the set of parameter names for this property.
* @return The set of parameter names for this property.
*/
public Set<String> getParameterNames() {
return mParamsMap.keySet();
}
/**
* Returns the list of parameters with the specified name. May return
* null if there are no such parameters.
* @param name The name of the parameters that should be returned.
* @return The list of parameters with the specified name.
*/
public List<Parameter> getParameters(String name) {
return mParamsMap.get(name);
}
/**
* Returns the first parameter with the specified name. May return
* nll if there is no such parameter.
* @param name The name of the parameter that should be returned.
* @return The first parameter with the specified name.
*/
public Parameter getFirstParameter(String name) {
ArrayList<Parameter> params = mParamsMap.get(name);
if (params == null || params.size() == 0) {
return null;
}
return params.get(0);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
toString(sb);
return sb.toString();
}
/**
* Helper method that appends this property to a StringBuilder. The
* caller is responsible for appending a newline after this property.
*/
public void toString(StringBuilder sb) {
sb.append(mName);
Set<String> parameterNames = getParameterNames();
for (String parameterName : parameterNames) {
for (Parameter param : getParameters(parameterName)) {
sb.append(";");
param.toString(sb);
}
}
sb.append(":");
sb.append(mValue);
}
}
/**
* A parameter defined for an iCalendar property.
*/
// TODO: make this a proper class rather than a struct?
public static class Parameter {
public String name;
public String value;
/**
* Creates a new empty parameter.
*/
public Parameter() {
}
/**
* Creates a new parameter with the specified name and value.
* @param name The name of the parameter.
* @param value The value of the parameter.
*/
public Parameter(String name, String value) {
this.name = name;
this.value = value;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
toString(sb);
return sb.toString();
}
/**
* Helper method that appends this parameter to a StringBuilder.
*/
public void toString(StringBuilder sb) {
sb.append(name);
sb.append("=");
sb.append(value);
}
}
private static final class ParserState {
// public int lineNumber = 0;
public String line; // TODO: just point to original text
public int index;
}
// use factory method
private ICalendar() {
}
// TODO: get rid of this -- handle all of the parsing in one pass through
// the text.
private static String normalizeText(String text) {
// it's supposed to be \r\n, but not everyone does that
text = text.replaceAll("\r\n", "\n");
text = text.replaceAll("\r", "\n");
// we deal with line folding, by replacing all "\n " strings
// with nothing. The RFC specifies "\r\n " to be folded, but
// we handle "\n " and "\r " too because we can get those.
text = text.replaceAll("\n ", "");
return text;
}
/**
* Parses text into an iCalendar component. Parses into the provided
* component, if not null, or parses into a new component. In the latter
* case, expects a BEGIN as the first line. Returns the provided or newly
* created top-level component.
*/
// TODO: use an index into the text, so we can make this a recursive
// function?
private static Component parseComponentImpl(Component component,
String text)
throws FormatException {
Component current = component;
ParserState state = new ParserState();
state.index = 0;
// split into lines
String[] lines = text.split("\n");
// each line is of the format:
// name *(";" param) ":" value
for (String line : lines) {
try {
current = parseLine(line, state, current);
// if the provided component was null, we will return the root
// NOTE: in this case, if the first line is not a BEGIN, a
// FormatException will get thrown.
if (component == null) {
component = current;
}
} catch (FormatException fe) {
if (false) {
Log.v(TAG, "Cannot parse " + line, fe);
}
// for now, we ignore the parse error. Google Calendar seems
// to be emitting some misformatted iCalendar objects.
}
continue;
}
return component;
}
/**
* Parses a line into the provided component. Creates a new component if
* the line is a BEGIN, adding the newly created component to the provided
* parent. Returns whatever component is the current one (to which new
* properties will be added) in the parse.
*/
private static Component parseLine(String line, ParserState state,
Component component)
throws FormatException {
state.line = line;
int len = state.line.length();
// grab the name
char c = 0;
for (state.index = 0; state.index < len; ++state.index) {
c = line.charAt(state.index);
if (c == ';' || c == ':') {
break;
}
}
String name = line.substring(0, state.index);
if (component == null) {
if (!Component.BEGIN.equals(name)) {
throw new FormatException("Expected BEGIN");
}
}
Property property;
if (Component.BEGIN.equals(name)) {
// start a new component
String componentName = extractValue(state);
Component child = new Component(componentName, component);
if (component != null) {
component.addChild(child);
}
return child;
} else if (Component.END.equals(name)) {
// finish the current component
String componentName = extractValue(state);
if (component == null ||
!componentName.equals(component.getName())) {
throw new FormatException("Unexpected END " + componentName);
}
return component.getParent();
} else {
property = new Property(name);
}
if (c == ';') {
Parameter parameter = null;
while ((parameter = extractParameter(state)) != null) {
property.addParameter(parameter);
}
}
String value = extractValue(state);
property.setValue(value);
component.addProperty(property);
return component;
}
/**
* Extracts the value ":..." on the current line. The first character must
* be a ':'.
*/
private static String extractValue(ParserState state)
throws FormatException {
String line = state.line;
if (state.index >= line.length() || line.charAt(state.index) != ':') {
throw new FormatException("Expected ':' before end of line in "
+ line);
}
String value = line.substring(state.index + 1);
state.index = line.length() - 1;
return value;
}
/**
* Extracts the next parameter from the line, if any. If there are no more
* parameters, returns null.
*/
private static Parameter extractParameter(ParserState state)
throws FormatException {
String text = state.line;
int len = text.length();
Parameter parameter = null;
int startIndex = -1;
int equalIndex = -1;
while (state.index < len) {
char c = text.charAt(state.index);
if (c == ':') {
if (parameter != null) {
if (equalIndex == -1) {
throw new FormatException("Expected '=' within "
+ "parameter in " + text);
}
parameter.value = text.substring(equalIndex + 1,
state.index);
}
return parameter; // may be null
} else if (c == ';') {
if (parameter != null) {
if (equalIndex == -1) {
throw new FormatException("Expected '=' within "
+ "parameter in " + text);
}
parameter.value = text.substring(equalIndex + 1,
state.index);
return parameter;
} else {
parameter = new Parameter();
startIndex = state.index;
}
} else if (c == '=') {
equalIndex = state.index;
if ((parameter == null) || (startIndex == -1)) {
throw new FormatException("Expected ';' before '=' in "
+ text);
}
parameter.name = text.substring(startIndex + 1, equalIndex);
} else if (c == '"') {
if (parameter == null) {
throw new FormatException("Expected parameter before '\"' in " + text);
}
if (equalIndex == -1) {
throw new FormatException("Expected '=' within parameter in " + text);
}
if (state.index > equalIndex + 1) {
throw new FormatException("Parameter value cannot contain a '\"' in " + text);
}
final int endQuote = text.indexOf('"', state.index + 1);
if (endQuote < 0) {
throw new FormatException("Expected closing '\"' in " + text);
}
parameter.value = text.substring(state.index + 1, endQuote);
state.index = endQuote + 1;
return parameter;
}
++state.index;
}
throw new FormatException("Expected ':' before end of line in " + text);
}
/**
* Parses the provided text into an iCalendar object. The top-level
* component must be of type VCALENDAR.
* @param text The text to be parsed.
* @return The top-level VCALENDAR component.
* @throws FormatException Thrown if the text could not be parsed into an
* iCalendar VCALENDAR object.
*/
public static Component parseCalendar(String text) throws FormatException {
Component calendar = parseComponent(null, text);
if (calendar == null || !Component.VCALENDAR.equals(calendar.getName())) {
throw new FormatException("Expected " + Component.VCALENDAR);
}
return calendar;
}
/**
* Parses the provided text into an iCalendar event. The top-level
* component must be of type VEVENT.
* @param text The text to be parsed.
* @return The top-level VEVENT component.
* @throws FormatException Thrown if the text could not be parsed into an
* iCalendar VEVENT.
*/
public static Component parseEvent(String text) throws FormatException {
Component event = parseComponent(null, text);
if (event == null || !Component.VEVENT.equals(event.getName())) {
throw new FormatException("Expected " + Component.VEVENT);
}
return event;
}
/**
* Parses the provided text into an iCalendar component.
* @param text The text to be parsed.
* @return The top-level component.
* @throws FormatException Thrown if the text could not be parsed into an
* iCalendar component.
*/
public static Component parseComponent(String text) throws FormatException {
return parseComponent(null, text);
}
/**
* Parses the provided text, adding to the provided component.
* @param component The component to which the parsed iCalendar data should
* be added.
* @param text The text to be parsed.
* @return The top-level component.
* @throws FormatException Thrown if the text could not be parsed as an
* iCalendar object.
*/
public static Component parseComponent(Component component, String text)
throws FormatException {
text = normalizeText(text);
return parseComponentImpl(component, text);
}
}