001package biweekly.io.text;
002
003import java.io.Closeable;
004import java.io.Flushable;
005import java.io.IOException;
006import java.io.Writer;
007import java.util.BitSet;
008import java.util.List;
009import java.util.Map;
010import java.util.regex.Pattern;
011
012import 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 * @see <a href="http://tools.ietf.org/html/rfc5545">RFC 5545</a>
043 */
044public 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         * @see <a href="http://tools.ietf.org/html/rfc6868">RFC 6868</a>
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         * @see <a href="http://tools.ietf.org/html/rfc6868">RFC 6868</a>
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}