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    }