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 }