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    }