001 package biweekly.io.text;
002
003 import java.io.Closeable;
004 import java.io.Flushable;
005 import java.io.IOException;
006 import java.io.Writer;
007 import java.util.BitSet;
008 import java.util.List;
009 import java.util.Map;
010 import java.util.regex.Pattern;
011
012 import 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 * @rfc 5545
043 */
044 public 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 * @rfc 6868
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 * @rfc 6868
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 }