001    package biweekly.io.text;
002    
003    import java.io.Closeable;
004    import java.io.Flushable;
005    import java.io.IOException;
006    import java.io.Writer;
007    import java.util.BitSet;
008    import java.util.List;
009    import java.util.Map;
010    import java.util.regex.Pattern;
011    
012    import 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     * @rfc 5545
043     */
044    public 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             * @rfc 6868
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             * @rfc 6868
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    }