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 }