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