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