001package biweekly.io.text; 002 003import java.io.Closeable; 004import java.io.Flushable; 005import java.io.IOException; 006import java.io.Writer; 007import java.util.BitSet; 008import java.util.List; 009import java.util.Map; 010import java.util.regex.Pattern; 011 012import biweekly.parameter.ICalParameters; 013 014/* 015 Copyright (c) 2013, Michael Angstadt 016 All rights reserved. 017 018 Redistribution and use in source and binary forms, with or without 019 modification, are permitted provided that the following conditions are met: 020 021 1. Redistributions of source code must retain the above copyright notice, this 022 list of conditions and the following disclaimer. 023 2. Redistributions in binary form must reproduce the above copyright notice, 024 this list of conditions and the following disclaimer in the documentation 025 and/or other materials provided with the distribution. 026 027 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 028 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 029 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 030 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 031 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 032 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 033 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 034 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 035 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 036 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 037 */ 038 039/** 040 * Writes data to an iCalendar data stream. 041 * @author Michael Angstadt 042 * @see <a href="http://tools.ietf.org/html/rfc5545">RFC 5545</a> 043 */ 044public class ICalRawWriter implements Closeable, Flushable { 045 /** 046 * Regular expression used to determine if a parameter value needs to be 047 * quoted. 048 */ 049 private static final Pattern quoteMeRegex = Pattern.compile(".*?[,:;].*"); 050 051 /** 052 * Regular expression used to detect newline character sequences. 053 */ 054 private static final Pattern newlineRegex = Pattern.compile("\\r\\n|\\r|\\n"); 055 056 /** 057 * Regular expression used to determine if a property name contains any 058 * invalid characters. 059 */ 060 private static final Pattern propertyNameRegex = Pattern.compile("(?i)[-a-z0-9]+"); 061 062 /** 063 * The characters that are not valid in parameter values and that should be 064 * removed. 065 */ 066 private static final BitSet invalidParamValueChars; 067 static { 068 invalidParamValueChars = new BitSet(128); 069 invalidParamValueChars.set(0, 31); 070 invalidParamValueChars.set(127); 071 invalidParamValueChars.set('\t', false); //allow 072 invalidParamValueChars.set('\n', false); //allow 073 invalidParamValueChars.set('\r', false); //allow 074 } 075 076 private final String newline; 077 private boolean caretEncodingEnabled = false; 078 private final FoldingScheme foldingScheme; 079 private final Writer writer; 080 private ParameterValueChangedListener parameterValueChangedListener; 081 082 /** 083 * Creates an iCalendar raw writer using the standard folding scheme and 084 * newline sequence. 085 * @param writer the writer to the data stream 086 */ 087 public ICalRawWriter(Writer writer) { 088 this(writer, FoldingScheme.DEFAULT); 089 } 090 091 /** 092 * Creates an iCalendar raw writer using the standard newline sequence. 093 * @param writer the writer to the data stream 094 * @param foldingScheme the folding scheme to use or null not to fold at all 095 */ 096 public ICalRawWriter(Writer writer, FoldingScheme foldingScheme) { 097 this(writer, foldingScheme, "\r\n"); 098 } 099 100 /** 101 * Creates an iCalendar raw writer. 102 * @param writer the writer to the data stream 103 * @param foldingScheme the folding scheme to use or null not to fold at all 104 * @param newline the newline sequence to use 105 */ 106 public ICalRawWriter(Writer writer, FoldingScheme foldingScheme, String newline) { 107 if (foldingScheme == null) { 108 this.writer = writer; 109 } else { 110 this.writer = new FoldedLineWriter(writer, foldingScheme.getLineLength(), foldingScheme.getIndent(), newline); 111 } 112 this.foldingScheme = foldingScheme; 113 this.newline = newline; 114 } 115 116 /** 117 * <p> 118 * Gets whether the writer will apply circumflex accent encoding on 119 * parameter values (disabled by default). This escaping mechanism allows 120 * for newlines and double quotes to be included in parameter values. 121 * </p> 122 * 123 * <p> 124 * When disabled, the writer will replace newlines with spaces and double 125 * quotes with single quotes. 126 * </p> 127 * 128 * <table border="1"> 129 * <tr> 130 * <th>Character</th> 131 * <th>Replacement<br> 132 * (when disabled)</th> 133 * <th>Replacement<br> 134 * (when enabled)</th> 135 * </tr> 136 * <tr> 137 * <td>{@code "}</td> 138 * <td>{@code '}</td> 139 * <td>{@code ^'}</td> 140 * </tr> 141 * <tr> 142 * <td><i>newline</i></td> 143 * <td><code><i>space</i></code></td> 144 * <td>{@code ^n}</td> 145 * </tr> 146 * <tr> 147 * <td>{@code ^}</td> 148 * <td>{@code ^}</td> 149 * <td>{@code ^^}</td> 150 * </tr> 151 * </table> 152 * 153 * <p> 154 * Example: 155 * </p> 156 * 157 * <pre> 158 * GEO;X-ADDRESS="Pittsburgh Pirates^n115 Federal St^nPitt 159 * sburgh, PA 15212":40.446816;80.00566 160 * </pre> 161 * 162 * @return true if circumflex accent encoding is enabled, false if not 163 * @see <a href="http://tools.ietf.org/html/rfc6868">RFC 6868</a> 164 */ 165 public boolean isCaretEncodingEnabled() { 166 return caretEncodingEnabled; 167 } 168 169 /** 170 * <p> 171 * Sets whether the writer will apply circumflex accent encoding on 172 * parameter values (disabled by default). This escaping mechanism allows 173 * for newlines and double quotes to be included in parameter values. 174 * </p> 175 * 176 * <p> 177 * When disabled, the writer will replace newlines with spaces and double 178 * quotes with single quotes. 179 * </p> 180 * 181 * <table border="1"> 182 * <tr> 183 * <th>Character</th> 184 * <th>Replacement<br> 185 * (when disabled)</th> 186 * <th>Replacement<br> 187 * (when enabled)</th> 188 * </tr> 189 * <tr> 190 * <td>{@code "}</td> 191 * <td>{@code '}</td> 192 * <td>{@code ^'}</td> 193 * </tr> 194 * <tr> 195 * <td><i>newline</i></td> 196 * <td><code><i>space</i></code></td> 197 * <td>{@code ^n}</td> 198 * </tr> 199 * <tr> 200 * <td>{@code ^}</td> 201 * <td>{@code ^}</td> 202 * <td>{@code ^^}</td> 203 * </tr> 204 * </table> 205 * 206 * <p> 207 * Example: 208 * </p> 209 * 210 * <pre> 211 * GEO;X-ADDRESS="Pittsburgh Pirates^n115 Federal St^nPitt 212 * sburgh, PA 15212":40.446816;80.00566 213 * </pre> 214 * 215 * @param enable true to use circumflex accent encoding, false not to 216 * @see <a href="http://tools.ietf.org/html/rfc6868">RFC 6868</a> 217 */ 218 public void setCaretEncodingEnabled(boolean enable) { 219 caretEncodingEnabled = enable; 220 } 221 222 /** 223 * Gets the newline sequence that is used to separate lines. 224 * @return the newline sequence 225 */ 226 public String getNewline() { 227 return newline; 228 } 229 230 /** 231 * Gets the listener which will be invoked when a parameter's value is 232 * changed due to containing invalid characters. 233 * @return the listener or null if not set 234 */ 235 public ParameterValueChangedListener getParameterValueChangedListener() { 236 return parameterValueChangedListener; 237 } 238 239 /** 240 * Sets the listener which will be invoked when a parameter's value is 241 * changed due to containing invalid characters. 242 * @param parameterValueChangedListener the listener or null to remove 243 */ 244 public void setParameterValueChangedListener(ParameterValueChangedListener parameterValueChangedListener) { 245 this.parameterValueChangedListener = parameterValueChangedListener; 246 } 247 248 /** 249 * Gets the rules for how each line is folded. 250 * @return the folding scheme or null if the lines are not folded 251 */ 252 public FoldingScheme getFoldingScheme() { 253 return foldingScheme; 254 } 255 256 /** 257 * Writes a property marking the beginning of a component (in other words, 258 * writes a "BEGIN:NAME" property). 259 * @param componentName the component name (e.g. "VEVENT") 260 * @throws IOException if there's an I/O problem 261 */ 262 public void writeBeginComponent(String componentName) throws IOException { 263 writeProperty("BEGIN", componentName); 264 } 265 266 /** 267 * Writes a property marking the end of a component (in other words, writes 268 * a "END:NAME" property). 269 * @param componentName the component name (e.g. "VEVENT") 270 * @throws IOException if there's an I/O problem 271 */ 272 public void writeEndComponent(String componentName) throws IOException { 273 writeProperty("END", componentName); 274 } 275 276 /** 277 * Writes a property to the iCalendar data stream. 278 * @param propertyName the property name (e.g. "VERSION") 279 * @param value the property value (e.g. "2.0") 280 * @throws IllegalArgumentException if the property name contains invalid 281 * characters 282 * @throws IOException if there's an I/O problem 283 */ 284 public void writeProperty(String propertyName, String value) throws IOException { 285 writeProperty(propertyName, new ICalParameters(), value); 286 } 287 288 /** 289 * Writes a property to the iCalendar data stream. 290 * @param propertyName the property name (e.g. "VERSION") 291 * @param parameters the property parameters 292 * @param value the property value (e.g. "2.0") 293 * @throws IllegalArgumentException if the property name contains invalid 294 * characters 295 * @throws IOException if there's an I/O problem 296 */ 297 public void writeProperty(String propertyName, ICalParameters parameters, String value) throws IOException { 298 //validate the property name 299 if (!propertyNameRegex.matcher(propertyName).matches()) { 300 throw new IllegalArgumentException("Property name invalid. Property names can only contain letters, numbers, and hyphens."); 301 } 302 303 //write the property name 304 writer.append(propertyName); 305 306 //write the parameters 307 for (Map.Entry<String, List<String>> subType : parameters) { 308 String parameterName = subType.getKey(); 309 List<String> parameterValues = subType.getValue(); 310 if (!parameterValues.isEmpty()) { 311 //e.g. ADR;TYPE=home,work,"another,value": 312 313 boolean first = true; 314 writer.append(';').append(parameterName).append('='); 315 for (String parameterValue : parameterValues) { 316 if (!first) { 317 writer.append(','); 318 } 319 320 parameterValue = sanitizeParameterValue(parameterValue, parameterName, propertyName); 321 322 //surround with double quotes if contains special chars 323 if (quoteMeRegex.matcher(parameterValue).matches()) { 324 writer.append('"'); 325 writer.append(parameterValue); 326 writer.append('"'); 327 } else { 328 writer.append(parameterValue); 329 } 330 331 first = false; 332 } 333 } 334 } 335 336 writer.append(':'); 337 338 //write the property value 339 if (value == null) { 340 value = ""; 341 } else { 342 value = escapeNewlines(value); 343 } 344 writer.append(value); 345 346 writer.append(newline); 347 } 348 349 /** 350 * Removes or escapes all invalid characters in a parameter value. 351 * @param parameterValue the parameter value 352 * @param parameterName the parameter name 353 * @param propertyName the name of the property to which the parameter 354 * belongs 355 * @return the sanitized parameter value 356 */ 357 private String sanitizeParameterValue(String parameterValue, String parameterName, String propertyName) { 358 boolean valueChanged = false; 359 String modifiedValue = removeInvalidParameterValueChars(parameterValue); 360 361 if (caretEncodingEnabled) { 362 valueChanged = (modifiedValue != parameterValue); 363 modifiedValue = applyCaretEncoding(modifiedValue); 364 } else { 365 //replace double quotes with single quotes 366 modifiedValue = modifiedValue.replace('"', '\''); 367 368 //replace newlines with spaces 369 modifiedValue = newlineRegex.matcher(modifiedValue).replaceAll(" "); 370 371 valueChanged = (modifiedValue != parameterValue); 372 } 373 374 if (valueChanged && parameterValueChangedListener != null) { 375 parameterValueChangedListener.onParameterValueChanged(propertyName, parameterName, parameterValue, modifiedValue); 376 } 377 378 return modifiedValue; 379 } 380 381 /** 382 * Removes invalid characters from a parameter value. 383 * @param value the parameter value 384 * @return the sanitized parameter value 385 */ 386 private String removeInvalidParameterValueChars(String value) { 387 StringBuilder sb = new StringBuilder(value.length()); 388 389 for (int i = 0; i < value.length(); i++) { 390 char ch = value.charAt(i); 391 if (!invalidParamValueChars.get(ch)) { 392 sb.append(ch); 393 } 394 } 395 396 return (sb.length() == value.length()) ? value : sb.toString(); 397 } 398 399 /** 400 * Applies circumflex accent encoding to a string. 401 * @param value the string 402 * @return the encoded string 403 */ 404 private String applyCaretEncoding(String value) { 405 value = value.replace("^", "^^"); 406 value = newlineRegex.matcher(value).replaceAll("^n"); 407 value = value.replace("\"", "^'"); 408 return value; 409 } 410 411 /** 412 * Escapes all newline characters. 413 * <p> 414 * This method escapes the following newline sequences: 415 * </p> 416 * <ul> 417 * <li>{@code \r\n}</li> 418 * <li>{@code \r}</li> 419 * <li>{@code \n}</li> 420 * </ul> 421 * @param text the text to escape 422 * @return the escaped text 423 */ 424 private String escapeNewlines(String text) { 425 return newlineRegex.matcher(text).replaceAll("\\\\n"); 426 } 427 428 /** 429 * Flushes the underlying {@link Writer} object. 430 * @throws IOException if there's a problem flushing the writer 431 */ 432 public void flush() throws IOException { 433 writer.flush(); 434 } 435 436 /** 437 * Closes the underlying {@link Writer} object. 438 */ 439 public void close() throws IOException { 440 writer.close(); 441 } 442 443 /** 444 * Allows you to respond to when a parameter's value is changed due to it 445 * containing invalid characters. If a character can be escaped (such as the 446 * "^" character when caret encoding is enabled), then this does not count 447 * as the parameter being modified because it can be decoded without losing 448 * any information. 449 * @author Michael Angstadt 450 */ 451 public static interface ParameterValueChangedListener { 452 /** 453 * Called when a parameter value is changed. 454 * @param propertyName the name of the property to which the parameter 455 * belongs 456 * @param parameterName the parameter name 457 * @param originalValue the original parameter value 458 * @param modifiedValue the modified parameter value 459 */ 460 void onParameterValueChanged(String propertyName, String parameterName, String originalValue, String modifiedValue); 461 } 462}