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