001package biweekly.io.xml;
002
003import static biweekly.io.xml.XCalNamespaceContext.XCAL_NS;
004import static biweekly.io.xml.XCalQNames.COMPONENTS;
005import static biweekly.io.xml.XCalQNames.ICALENDAR;
006import static biweekly.io.xml.XCalQNames.PARAMETERS;
007import static biweekly.io.xml.XCalQNames.PROPERTIES;
008import static biweekly.util.IOUtils.utf8Writer;
009
010import java.io.File;
011import java.io.IOException;
012import java.io.OutputStream;
013import java.io.Writer;
014import java.util.Collection;
015import java.util.HashMap;
016import java.util.List;
017import java.util.Map;
018
019import javax.xml.namespace.QName;
020import javax.xml.transform.Result;
021import javax.xml.transform.TransformerConfigurationException;
022import javax.xml.transform.TransformerFactory;
023import javax.xml.transform.dom.DOMResult;
024import javax.xml.transform.sax.SAXTransformerFactory;
025import javax.xml.transform.sax.TransformerHandler;
026import javax.xml.transform.stream.StreamResult;
027
028import org.w3c.dom.Document;
029import org.w3c.dom.Element;
030import org.w3c.dom.NamedNodeMap;
031import org.w3c.dom.Node;
032import org.w3c.dom.NodeList;
033import org.w3c.dom.Text;
034import org.xml.sax.Attributes;
035import org.xml.sax.SAXException;
036import org.xml.sax.helpers.AttributesImpl;
037
038import biweekly.ICalDataType;
039import biweekly.ICalVersion;
040import biweekly.ICalendar;
041import biweekly.component.ICalComponent;
042import biweekly.component.VTimezone;
043import biweekly.io.SkipMeException;
044import biweekly.io.StreamWriter;
045import biweekly.io.scribe.component.ICalComponentScribe;
046import biweekly.io.scribe.property.ICalPropertyScribe;
047import biweekly.parameter.ICalParameters;
048import biweekly.property.ICalProperty;
049import biweekly.property.Version;
050import biweekly.property.Xml;
051import biweekly.util.StringUtils;
052import biweekly.util.XmlUtils;
053
054/*
055 Copyright (c) 2013-2015, Michael Angstadt
056 All rights reserved.
057
058 Redistribution and use in source and binary forms, with or without
059 modification, are permitted provided that the following conditions are met: 
060
061 1. Redistributions of source code must retain the above copyright notice, this
062 list of conditions and the following disclaimer. 
063 2. Redistributions in binary form must reproduce the above copyright notice,
064 this list of conditions and the following disclaimer in the documentation
065 and/or other materials provided with the distribution. 
066
067 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
068 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
069 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
070 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
071 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
072 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
073 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
074 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
075 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
076 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
077
078 The views and conclusions contained in the software and documentation are those
079 of the authors and should not be interpreted as representing official policies, 
080 either expressed or implied, of the FreeBSD Project.
081 */
082
083/**
084 * <p>
085 * Writes xCards (XML-encoded iCalendar objects) in a streaming fashion.
086 * </p>
087 * <p>
088 * <b>Example:</b>
089 * 
090 * <pre class="brush:java">
091 * ICalendar ical1 = ...
092 * ICalendar ical2 = ...
093 * File file = new File("icals.xml");
094 * XCalWriter writer = null;
095 * try {
096 *   writer = new XCalWriter(file);
097 *   writer.write(ical1);
098 *   writer.write(ical2);
099 * } finally {
100 *   if (writer != null) writer.close();
101 * }
102 * </pre>
103 * 
104 * </p>
105 * 
106 * <p>
107 * <b>Changing the timezone settings:</b>
108 * 
109 * <pre class="brush:java">
110 * XCalWriter writer = new XCalWriter(...);
111 * 
112 * //format all date/time values in a specific timezone instead of UTC
113 * //note: this makes an HTTP call to the "tzurl.org" website
114 * writer.getTimezoneInfo().setDefaultTimeZone(TimeZone.getDefault());
115 * 
116 * //format the value of a particular date/time property in a specific timezone instead of UTC
117 * //note: this makes an HTTP call to the "tzurl.org" website
118 * DateStart dtstart = ...
119 * writer.getTimezoneInfo().setTimeZone(dtstart, TimeZone.getDefault());
120 * 
121 * //generate Outlook-friendly VTIMEZONE components:
122 * writer.getTimezoneInfo().setGenerator(new TzUrlDotOrgGenerator(true));
123 * </pre>
124 * 
125 * </p>
126 * 
127 * @author Michael Angstadt
128 * @see <a href="http://tools.ietf.org/html/rfc6351">RFC 6351</a>
129 */
130public class XCalWriter extends StreamWriter {
131        //How to use SAX to write XML: http://stackoverflow.com/q/4898590
132        private final Document DOC = XmlUtils.createDocument();
133
134        /**
135         * Defines the names of the XML elements that are used to hold each
136         * parameter's value.
137         */
138        private final Map<String, ICalDataType> parameterDataTypes = new HashMap<String, ICalDataType>();
139        {
140                registerParameterDataType(ICalParameters.CN, ICalDataType.TEXT);
141                registerParameterDataType(ICalParameters.ALTREP, ICalDataType.URI);
142                registerParameterDataType(ICalParameters.CUTYPE, ICalDataType.TEXT);
143                registerParameterDataType(ICalParameters.DELEGATED_FROM, ICalDataType.CAL_ADDRESS);
144                registerParameterDataType(ICalParameters.DELEGATED_TO, ICalDataType.CAL_ADDRESS);
145                registerParameterDataType(ICalParameters.DIR, ICalDataType.URI);
146                registerParameterDataType(ICalParameters.ENCODING, ICalDataType.TEXT);
147                registerParameterDataType(ICalParameters.FMTTYPE, ICalDataType.TEXT);
148                registerParameterDataType(ICalParameters.FBTYPE, ICalDataType.TEXT);
149                registerParameterDataType(ICalParameters.LANGUAGE, ICalDataType.TEXT);
150                registerParameterDataType(ICalParameters.MEMBER, ICalDataType.CAL_ADDRESS);
151                registerParameterDataType(ICalParameters.PARTSTAT, ICalDataType.TEXT);
152                registerParameterDataType(ICalParameters.RANGE, ICalDataType.TEXT);
153                registerParameterDataType(ICalParameters.RELATED, ICalDataType.TEXT);
154                registerParameterDataType(ICalParameters.RELTYPE, ICalDataType.TEXT);
155                registerParameterDataType(ICalParameters.ROLE, ICalDataType.TEXT);
156                registerParameterDataType(ICalParameters.RSVP, ICalDataType.BOOLEAN);
157                registerParameterDataType(ICalParameters.SENT_BY, ICalDataType.CAL_ADDRESS);
158                registerParameterDataType(ICalParameters.TZID, ICalDataType.TEXT);
159        }
160
161        private final Writer writer;
162        private final ICalVersion targetVersion = ICalVersion.V2_0;
163        private final TransformerHandler handler;
164        private final boolean icalendarElementExists;
165        private String indent;
166        private int level = 0;
167        private boolean textNodeJustPrinted = false, started = false;
168
169        /**
170         * @param out the output stream to write to (UTF-8 encoding will be used)
171         */
172        public XCalWriter(OutputStream out) {
173                this(utf8Writer(out));
174        }
175
176        /**
177         * @param file the file to write to (UTF-8 encoding will be used).
178         * @throws IOException if there's a problem opening the file
179         */
180        public XCalWriter(File file) throws IOException {
181                this(utf8Writer(file));
182        }
183
184        /**
185         * @param writer the writer to write to
186         */
187        public XCalWriter(Writer writer) {
188                this(writer, null);
189        }
190
191        /**
192         * @param parent the DOM node to add child elements to
193         */
194        public XCalWriter(Node parent) {
195                this(null, parent);
196        }
197
198        private XCalWriter(Writer writer, Node parent) {
199                this.writer = writer;
200
201                if (parent instanceof Document) {
202                        Node root = parent.getFirstChild();
203                        if (root != null) {
204                                parent = root;
205                        }
206                }
207                this.icalendarElementExists = isICalendarElement(parent);
208
209                try {
210                        SAXTransformerFactory factory = (SAXTransformerFactory) TransformerFactory.newInstance();
211                        handler = factory.newTransformerHandler();
212                } catch (TransformerConfigurationException e) {
213                        throw new RuntimeException(e);
214                }
215
216                Result result = (writer == null) ? new DOMResult(parent) : new StreamResult(writer);
217                handler.setResult(result);
218        }
219
220        /**
221         * Set the indentation string to use for pretty-printing the output.
222         * @param indent the indentation string (e.g. 2 spaces) or null to disable
223         * pretty-printing (defaults to null)
224         */
225        public void setIndent(String indent) {
226                this.indent = indent;
227        }
228
229        private boolean isICalendarElement(Node node) {
230                if (node == null) {
231                        return false;
232                }
233
234                if (!(node instanceof Element)) {
235                        return false;
236                }
237
238                return XmlUtils.hasQName(node, ICALENDAR);
239        }
240
241        /**
242         * Registers the data type of an experimental parameter. Experimental
243         * parameters use the "unknown" data type by default.
244         * @param parameterName the parameter name (e.g. "x-foo")
245         * @param dataType the data type or null to remove
246         */
247        public void registerParameterDataType(String parameterName, ICalDataType dataType) {
248                parameterName = parameterName.toLowerCase();
249                if (dataType == null) {
250                        parameterDataTypes.remove(parameterName);
251                } else {
252                        parameterDataTypes.put(parameterName, dataType);
253                }
254        }
255
256        @Override
257        protected void _write(ICalendar ical) throws IOException {
258                try {
259                        if (!started) {
260                                handler.startDocument();
261
262                                if (!icalendarElementExists) {
263                                        //don't output a <icalendar> element if the parent is a <icalendar> element
264                                        start(ICALENDAR);
265                                        level++;
266                                }
267
268                                started = true;
269                        }
270
271                        write((ICalComponent) ical);
272                } catch (SAXException e) {
273                        throw new IOException(e);
274                }
275        }
276
277        @Override
278        protected ICalVersion getTargetVersion() {
279                return targetVersion;
280        }
281
282        @SuppressWarnings({ "rawtypes", "unchecked" })
283        private void write(ICalComponent component) throws SAXException {
284                ICalComponentScribe scribe = index.getComponentScribe(component);
285                String name = scribe.getComponentName().toLowerCase();
286
287                start(name);
288                level++;
289
290                List properties = scribe.getProperties(component);
291                if (component instanceof ICalendar && component.getProperty(Version.class) == null) {
292                        properties.add(0, new Version(targetVersion));
293                }
294
295                if (!properties.isEmpty()) {
296                        start(PROPERTIES);
297                        level++;
298
299                        for (Object propertyObj : properties) {
300                                context.setParent(component); //set parent here incase a scribe resets the parent
301                                ICalProperty property = (ICalProperty) propertyObj;
302                                write(property);
303                        }
304
305                        level--;
306                        end(PROPERTIES);
307                }
308
309                Collection subComponents = scribe.getComponents(component);
310                if (component instanceof ICalendar) {
311                        //add the VTIMEZONE components that were auto-generated by TimezoneOptions
312                        Collection<VTimezone> tzs = tzinfo.getComponents();
313                        for (VTimezone tz : tzs) {
314                                if (!subComponents.contains(tz)) {
315                                        subComponents.add(tz);
316                                }
317                        }
318                }
319                if (!subComponents.isEmpty()) {
320                        start(COMPONENTS);
321                        level++;
322
323                        for (Object subComponentObj : subComponents) {
324                                ICalComponent subComponent = (ICalComponent) subComponentObj;
325                                write(subComponent);
326                        }
327
328                        level--;
329                        end(COMPONENTS);
330                }
331
332                level--;
333                end(name);
334        }
335
336        @SuppressWarnings({ "rawtypes", "unchecked" })
337        private void write(ICalProperty property) throws SAXException {
338                ICalPropertyScribe scribe = index.getPropertyScribe(property);
339                ICalParameters parameters = scribe.prepareParameters(property, context);
340
341                //get the property element to write
342                Element propertyElement;
343                if (property instanceof Xml) {
344                        Xml xml = (Xml) property;
345                        Document value = xml.getValue();
346                        if (value == null) {
347                                return;
348                        }
349                        propertyElement = XmlUtils.getRootElement(value);
350                } else {
351                        QName qname = scribe.getQName();
352                        propertyElement = DOC.createElementNS(qname.getNamespaceURI(), qname.getLocalPart());
353                        try {
354                                scribe.writeXml(property, propertyElement, context);
355                        } catch (SkipMeException e) {
356                                return;
357                        }
358                }
359
360                start(propertyElement);
361                level++;
362
363                write(parameters);
364                write(propertyElement);
365
366                level--;
367                end(propertyElement);
368        }
369
370        private void write(Element propertyElement) throws SAXException {
371                NodeList children = propertyElement.getChildNodes();
372                for (int i = 0; i < children.getLength(); i++) {
373                        Node child = children.item(i);
374
375                        if (child instanceof Element) {
376                                Element element = (Element) child;
377
378                                if (element.hasChildNodes()) {
379                                        start(element);
380                                        level++;
381
382                                        write(element);
383
384                                        level--;
385                                        end(element);
386                                } else {
387                                        childless(element);
388                                }
389
390                                continue;
391                        }
392
393                        if (child instanceof Text) {
394                                Text text = (Text) child;
395                                text(text.getTextContent());
396                                continue;
397                        }
398                }
399        }
400
401        private void write(ICalParameters parameters) throws SAXException {
402                if (parameters.isEmpty()) {
403                        return;
404                }
405
406                start(PARAMETERS);
407                level++;
408
409                for (Map.Entry<String, List<String>> parameter : parameters) {
410                        String parameterName = parameter.getKey().toLowerCase();
411                        start(parameterName);
412                        level++;
413
414                        for (String parameterValue : parameter.getValue()) {
415                                ICalDataType dataType = parameterDataTypes.get(parameterName);
416                                String dataTypeElementName = (dataType == null) ? "unknown" : dataType.getName().toLowerCase();
417
418                                start(dataTypeElementName);
419                                text(parameterValue);
420                                end(dataTypeElementName);
421                        }
422
423                        level--;
424                        end(parameterName);
425                }
426
427                level--;
428                end(PARAMETERS);
429        }
430
431        private void indent() throws SAXException {
432                if (indent == null) {
433                        return;
434                }
435
436                /*
437                 * "\n" is hard-coded here because if the Windows "\r\n" is used, it
438                 * will encode the "\r" character for XML ("&#13;")
439                 */
440                String str = '\n' + StringUtils.repeat(indent, level);
441                handler.ignorableWhitespace(str.toCharArray(), 0, str.length());
442        }
443
444        /**
445         * Makes an childless element appear as {@code<foo />} instead of
446         * {@code<foo></foo>}
447         * @param element the element
448         * @throws SAXException
449         */
450        private void childless(Element element) throws SAXException {
451                Attributes attributes = getElementAttributes(element);
452                indent();
453                handler.startElement(element.getNamespaceURI(), "", element.getLocalName(), attributes);
454                handler.endElement(element.getNamespaceURI(), "", element.getLocalName());
455        }
456
457        private void start(Element element) throws SAXException {
458                Attributes attributes = getElementAttributes(element);
459                start(element.getNamespaceURI(), element.getLocalName(), attributes);
460        }
461
462        private void start(String element) throws SAXException {
463                start(element, null);
464        }
465
466        private void start(QName qname) throws SAXException {
467                start(qname, null);
468        }
469
470        private void start(QName qname, Attributes attributes) throws SAXException {
471                start(qname.getNamespaceURI(), qname.getLocalPart(), attributes);
472        }
473
474        private void start(String element, Attributes attributes) throws SAXException {
475                start(XCAL_NS, element, attributes);
476        }
477
478        private void start(String namespace, String element, Attributes attributes) throws SAXException {
479                indent();
480                handler.startElement(namespace, "", element, attributes);
481        }
482
483        private void end(Element element) throws SAXException {
484                end(element.getNamespaceURI(), element.getLocalName());
485        }
486
487        private void end(String element) throws SAXException {
488                end(XCAL_NS, element);
489        }
490
491        private void end(QName qname) throws SAXException {
492                end(qname.getNamespaceURI(), qname.getLocalPart());
493        }
494
495        private void end(String namespace, String element) throws SAXException {
496                if (!textNodeJustPrinted) {
497                        indent();
498                }
499
500                handler.endElement(namespace, "", element);
501                textNodeJustPrinted = false;
502        }
503
504        private void text(String text) throws SAXException {
505                handler.characters(text.toCharArray(), 0, text.length());
506                textNodeJustPrinted = true;
507        }
508
509        private Attributes getElementAttributes(Element element) {
510                AttributesImpl attributes = new AttributesImpl();
511                NamedNodeMap attributeNodes = element.getAttributes();
512                for (int i = 0; i < attributeNodes.getLength(); i++) {
513                        Node node = attributeNodes.item(i);
514                        attributes.addAttribute(node.getNamespaceURI(), "", node.getLocalName(), "", node.getNodeValue());
515                }
516                return attributes;
517        }
518
519        /**
520         * Terminates the XML document and closes the output stream.
521         */
522        public void close() throws IOException {
523                try {
524                        if (!started) {
525                                handler.startDocument();
526
527                                if (!icalendarElementExists) {
528                                        //don't output a <icalendar> element if the parent is a <icalendar> element
529                                        start(ICALENDAR);
530                                        level++;
531                                }
532                        }
533
534                        if (!icalendarElementExists) {
535                                level--;
536                                end(ICALENDAR);
537                        }
538                        handler.endDocument();
539                } catch (SAXException e) {
540                        throw new IOException(e);
541                }
542
543                if (writer != null) {
544                        writer.close();
545                }
546        }
547}