001package biweekly.io.text;
002
003import java.io.Closeable;
004import java.io.Flushable;
005import java.io.IOException;
006import java.io.Writer;
007import java.nio.charset.Charset;
008import java.util.BitSet;
009import java.util.Collections;
010import java.util.HashMap;
011import java.util.List;
012import java.util.Map;
013import java.util.regex.Pattern;
014
015import biweekly.ICalVersion;
016import biweekly.parameter.Encoding;
017import biweekly.parameter.ICalParameters;
018
019/*
020 Copyright (c) 2013-2015, Michael Angstadt
021 All rights reserved.
022
023 Redistribution and use in source and binary forms, with or without
024 modification, are permitted provided that the following conditions are met: 
025
026 1. Redistributions of source code must retain the above copyright notice, this
027 list of conditions and the following disclaimer. 
028 2. Redistributions in binary form must reproduce the above copyright notice,
029 this list of conditions and the following disclaimer in the documentation
030 and/or other materials provided with the distribution. 
031
032 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
033 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
034 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
035 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
036 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
037 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
038 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
039 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
040 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
041 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
042 */
043
044/**
045 * Writes data to an iCalendar data stream.
046 * @author Michael Angstadt
047 * @see <a href="http://www.imc.org/pdi/pdiproddev.html">1.0 specs</a>
048 * @see <a href="https://tools.ietf.org/html/rfc2445">RFC 2445</a>
049 * @see <a href="http://tools.ietf.org/html/rfc5545">RFC 5545</a>
050 */
051public class ICalRawWriter implements Closeable, Flushable {
052        /**
053         * Regular expression used to determine if a parameter value needs to be
054         * quoted.
055         */
056        private static final Pattern quoteMeRegex = Pattern.compile(".*?[,:;].*");
057
058        /**
059         * Regular expression used to detect newline character sequences.
060         */
061        private static final Pattern newlineRegex = Pattern.compile("\\r\\n|\\r|\\n");
062
063        /**
064         * Regular expression used to determine if a property name contains any
065         * invalid characters.
066         */
067        private static final Pattern propertyNameRegex = Pattern.compile("(?i)[-a-z0-9]+");
068
069        /**
070         * The characters that are not valid in parameter values and that should be
071         * removed.
072         */
073        private static final Map<ICalVersion, BitSet> invalidParamValueChars;
074        static {
075                BitSet controlChars = new BitSet(128);
076                controlChars.set(0, 31);
077                controlChars.set(127);
078                controlChars.set('\t', false); //allow
079                controlChars.set('\n', false); //allow
080                controlChars.set('\r', false); //allow
081
082                Map<ICalVersion, BitSet> map = new HashMap<ICalVersion, BitSet>();
083
084                //1.0
085                {
086                        BitSet bitSet = new BitSet(128);
087                        bitSet.or(controlChars);
088
089                        bitSet.set(',');
090                        bitSet.set('.');
091                        bitSet.set(':');
092                        bitSet.set('=');
093                        bitSet.set('[');
094                        bitSet.set(']');
095
096                        map.put(ICalVersion.V1_0, bitSet);
097                }
098
099                //2.0
100                {
101                        BitSet bitSet = new BitSet(128);
102                        bitSet.or(controlChars);
103
104                        map.put(ICalVersion.V2_0_DEPRECATED, bitSet);
105                        map.put(ICalVersion.V2_0, bitSet);
106                }
107
108                invalidParamValueChars = Collections.unmodifiableMap(map);
109        }
110
111        private final FoldedLineWriter writer;
112        private boolean caretEncodingEnabled = false;
113        private ICalVersion version;
114
115        /**
116         * @param writer the writer to wrap
117         * @param version the version to adhere to
118         */
119        public ICalRawWriter(Writer writer, ICalVersion version) {
120                this.writer = new FoldedLineWriter(writer);
121                this.version = version;
122        }
123
124        /**
125         * Gets the writer that this object wraps.
126         * @return the folded line writer
127         */
128        public FoldedLineWriter getFoldedLineWriter() {
129                return writer;
130        }
131
132        /**
133         * <p>
134         * Gets whether the writer will apply circumflex accent encoding on
135         * parameter values (disabled by default). This escaping mechanism allows
136         * for newlines and double quotes to be included in parameter values.
137         * </p>
138         * 
139         * <p>
140         * When disabled, the writer will replace newlines with spaces and double
141         * quotes with single quotes.
142         * </p>
143         * 
144         * <table border="1">
145         * <tr>
146         * <th>Character</th>
147         * <th>Replacement<br>
148         * (when disabled)</th>
149         * <th>Replacement<br>
150         * (when enabled)</th>
151         * </tr>
152         * <tr>
153         * <td>{@code "}</td>
154         * <td>{@code '}</td>
155         * <td>{@code ^'}</td>
156         * </tr>
157         * <tr>
158         * <td><i>newline</i></td>
159         * <td><code><i>space</i></code></td>
160         * <td>{@code ^n}</td>
161         * </tr>
162         * <tr>
163         * <td>{@code ^}</td>
164         * <td>{@code ^}</td>
165         * <td>{@code ^^}</td>
166         * </tr>
167         * </table>
168         * 
169         * <p>
170         * Example:
171         * </p>
172         * 
173         * <pre>
174         * GEO;X-ADDRESS="Pittsburgh Pirates^n115 Federal St^nPitt
175         *  sburgh, PA 15212":40.446816;80.00566
176         * </pre>
177         * 
178         * @return true if circumflex accent encoding is enabled, false if not
179         * @see <a href="http://tools.ietf.org/html/rfc6868">RFC 6868</a>
180         */
181        public boolean isCaretEncodingEnabled() {
182                return caretEncodingEnabled;
183        }
184
185        /**
186         * <p>
187         * Sets whether the writer will apply circumflex accent encoding on
188         * parameter values (disabled by default). This escaping mechanism allows
189         * for newlines and double quotes to be included in parameter values.
190         * </p>
191         * 
192         * <p>
193         * When disabled, the writer will replace newlines with spaces and double
194         * quotes with single quotes.
195         * </p>
196         * 
197         * <table border="1">
198         * <tr>
199         * <th>Character</th>
200         * <th>Replacement<br>
201         * (when disabled)</th>
202         * <th>Replacement<br>
203         * (when enabled)</th>
204         * </tr>
205         * <tr>
206         * <td>{@code "}</td>
207         * <td>{@code '}</td>
208         * <td>{@code ^'}</td>
209         * </tr>
210         * <tr>
211         * <td><i>newline</i></td>
212         * <td><code><i>space</i></code></td>
213         * <td>{@code ^n}</td>
214         * </tr>
215         * <tr>
216         * <td>{@code ^}</td>
217         * <td>{@code ^}</td>
218         * <td>{@code ^^}</td>
219         * </tr>
220         * </table>
221         * 
222         * <p>
223         * Example:
224         * </p>
225         * 
226         * <pre>
227         * GEO;X-ADDRESS="Pittsburgh Pirates^n115 Federal St^nPitt
228         *  sburgh, PA 15212":40.446816;80.00566
229         * </pre>
230         * 
231         * @param enable true to use circumflex accent encoding, false not to
232         * @see <a href="http://tools.ietf.org/html/rfc6868">RFC 6868</a>
233         */
234        public void setCaretEncodingEnabled(boolean enable) {
235                caretEncodingEnabled = enable;
236        }
237
238        /**
239         * Gets the iCalendar version that the writer is adhering to.
240         * @return the version
241         */
242        public ICalVersion getVersion() {
243                return version;
244        }
245
246        /**
247         * Sets the iCalendar version that the writer should adhere to.
248         * @param version the version
249         */
250        public void setVersion(ICalVersion version) {
251                this.version = version;
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 "VERSION" property, based on the iCalendar version that the
276         * writer is adhering to.
277         * @throws IOException if there's an I/O problem
278         */
279        public void writeVersion() throws IOException {
280                writeProperty("VERSION", version.getVersion());
281        }
282
283        /**
284         * Writes a property to the iCalendar data stream.
285         * @param propertyName the property name (e.g. "VERSION")
286         * @param value the property value (e.g. "2.0")
287         * @throws IllegalArgumentException if the property name contains invalid
288         * characters
289         * @throws IOException if there's an I/O problem
290         */
291        public void writeProperty(String propertyName, String value) throws IOException {
292                writeProperty(propertyName, new ICalParameters(), value);
293        }
294
295        /**
296         * Writes a property to the iCalendar data stream.
297         * @param propertyName the property name (e.g. "VERSION")
298         * @param parameters the property parameters
299         * @param value the property value (e.g. "2.0")
300         * @throws IllegalArgumentException if the property name contains invalid
301         * characters
302         * @throws IOException if there's an I/O problem
303         */
304        public void writeProperty(String propertyName, ICalParameters parameters, String value) throws IOException {
305                //validate the property name
306                if (!propertyNameRegex.matcher(propertyName).matches()) {
307                        throw new IllegalArgumentException("Property name invalid.  Property names can only contain letters, numbers, and hyphens.");
308                }
309
310                value = sanitizeValue(parameters, value);
311
312                /*
313                 * Determine if the property value must be encoded in quoted printable
314                 * encoding. If so, then determine what charset to use for the encoding.
315                 */
316                boolean useQuotedPrintable = (parameters.getEncoding() == Encoding.QUOTED_PRINTABLE);
317                Charset quotedPrintableCharset = null;
318                if (useQuotedPrintable) {
319                        String charsetParam = parameters.getCharset();
320                        if (charsetParam == null) {
321                                quotedPrintableCharset = Charset.forName("UTF-8");
322                        } else {
323                                try {
324                                        quotedPrintableCharset = Charset.forName(charsetParam);
325                                } catch (Throwable t) {
326                                        quotedPrintableCharset = Charset.forName("UTF-8");
327                                }
328                        }
329                        parameters.setCharset(quotedPrintableCharset.name());
330                }
331
332                //write the property name
333                writer.append(propertyName);
334
335                //write the parameters
336                for (Map.Entry<String, List<String>> subType : parameters) {
337                        String parameterName = subType.getKey();
338                        List<String> parameterValues = subType.getValue();
339                        if (parameterValues.isEmpty()) {
340                                continue;
341                        }
342
343                        if (version == ICalVersion.V1_0) {
344                                //e.g. ADR;FOO=bar;FOO=car:
345                                for (String parameterValue : parameterValues) {
346                                        parameterValue = sanitizeParameterValue(parameterValue, parameterName, propertyName);
347                                        writer.append(';').append(parameterName).append('=').append(parameterValue);
348                                }
349                                continue;
350                        }
351
352                        //e.g. ADR;TYPE=home,work,"another,value":
353                        boolean first = true;
354                        writer.append(';').append(parameterName).append('=');
355                        for (String parameterValue : parameterValues) {
356                                if (!first) {
357                                        writer.append(',');
358                                }
359
360                                parameterValue = sanitizeParameterValue(parameterValue, parameterName, propertyName);
361
362                                //surround with double quotes if contains special chars
363                                if (containsSpecialChars(parameterValue)) {
364                                        writer.append('"').append(parameterValue).append('"');
365                                } else {
366                                        writer.append(parameterValue);
367                                }
368
369                                first = false;
370                        }
371                }
372
373                writer.append(':');
374
375                //write the property value
376                writer.append(value, useQuotedPrintable, quotedPrintableCharset);
377                writer.append(writer.getNewline());
378        }
379
380        /**
381         * Sanitizes a property value for safe inclusion in an iCalendar object.
382         * @param parameters the property's parameters
383         * @param value the value to sanitize
384         * @return the sanitized value
385         */
386        private String sanitizeValue(ICalParameters parameters, String value) {
387                if (value == null) {
388                        return "";
389                }
390
391                if (version == ICalVersion.V1_0 && containsNewlines(value)) {
392                        /*
393                         * 1.0 does not support the "\n" escape sequence (see "Delimiters"
394                         * sub-section in section 2 of the specs)
395                         */
396                        parameters.setEncoding(Encoding.QUOTED_PRINTABLE);
397                        return value;
398                }
399
400                return escapeNewlines(value);
401        }
402
403        /**
404         * Removes or escapes all invalid characters in a parameter value.
405         * @param parameterValue the parameter value
406         * @param parameterName the parameter name
407         * @param propertyName the name of the property to which the parameter
408         * belongs
409         * @return the sanitized parameter value
410         */
411        private String sanitizeParameterValue(String parameterValue, String parameterName, String propertyName) {
412                //remove invalid characters
413                parameterValue = removeInvalidParameterValueChars(parameterValue);
414
415                switch (version) {
416                case V1_0:
417                        //replace newlines with spaces
418                        parameterValue = newlineRegex.matcher(parameterValue).replaceAll(" ");
419
420                        //escape backslashes
421                        parameterValue = parameterValue.replace("\\", "\\\\");
422
423                        //escape semi-colons (see section 2)
424                        parameterValue = parameterValue.replace(";", "\\;");
425
426                        break;
427
428                default:
429                        if (caretEncodingEnabled) {
430                                //apply caret encoding
431                                parameterValue = applyCaretEncoding(parameterValue);
432                        } else {
433                                //replace double quotes with single quotes
434                                parameterValue = parameterValue.replace('"', '\'');
435
436                                //replace newlines with spaces
437                                parameterValue = newlineRegex.matcher(parameterValue).replaceAll(" ");
438                        }
439
440                        break;
441                }
442
443                return parameterValue;
444        }
445
446        /**
447         * Removes invalid characters from a parameter value.
448         * @param value the parameter value
449         * @return the sanitized parameter value
450         */
451        private String removeInvalidParameterValueChars(String value) {
452                BitSet invalidChars = invalidParamValueChars.get(version);
453                StringBuilder sb = null;
454
455                for (int i = 0; i < value.length(); i++) {
456                        char ch = value.charAt(i);
457                        if (invalidChars.get(ch)) {
458                                if (sb == null) {
459                                        sb = new StringBuilder(value.length());
460                                        sb.append(value.substring(0, i));
461                                }
462                                continue;
463                        }
464
465                        if (sb != null) {
466                                sb.append(ch);
467                        }
468                }
469
470                return (sb == null) ? value : sb.toString();
471        }
472
473        /**
474         * Applies circumflex accent encoding to a string.
475         * @param value the string
476         * @return the encoded string
477         */
478        private String applyCaretEncoding(String value) {
479                value = value.replace("^", "^^");
480                value = newlineRegex.matcher(value).replaceAll("^n");
481                value = value.replace("\"", "^'");
482                return value;
483        }
484
485        /**
486         * Escapes all newline characters.
487         * <p>
488         * This method escapes the following newline sequences:
489         * </p>
490         * <ul>
491         * <li>{@code \r\n}</li>
492         * <li>{@code \r}</li>
493         * <li>{@code \n}</li>
494         * </ul>
495         * @param text the text to escape
496         * @return the escaped text
497         */
498        private String escapeNewlines(String text) {
499                return newlineRegex.matcher(text).replaceAll("\\\\n");
500        }
501
502        /**
503         * <p>
504         * Determines if a string has at least one newline character sequence. The
505         * newline character sequences are:
506         * </p>
507         * <ul>
508         * <li>{@code \r\n}</li>
509         * <li>{@code \r}</li>
510         * <li>{@code \n}</li>
511         * </ul>
512         * @param text the text to escape
513         * @return the escaped text
514         */
515        private boolean containsNewlines(String text) {
516                return newlineRegex.matcher(text).find();
517        }
518
519        /**
520         * Determines if a parameter value contains special characters.
521         * @param parameterValue the parameter value
522         * @return true if it contains special characters, false if not
523         */
524        private boolean containsSpecialChars(String parameterValue) {
525                return quoteMeRegex.matcher(parameterValue).matches();
526        }
527
528        /**
529         * Flushes the underlying {@link Writer} object.
530         * @throws IOException if there's a problem flushing the writer
531         */
532        public void flush() throws IOException {
533                writer.flush();
534        }
535
536        /**
537         * Closes the underlying {@link Writer} object.
538         */
539        public void close() throws IOException {
540                writer.close();
541        }
542}