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;
009import static biweekly.util.StringUtils.NEWLINE;
010
011import java.io.File;
012import java.io.IOException;
013import java.io.OutputStream;
014import java.io.Writer;
015import java.util.Collection;
016import java.util.HashMap;
017import java.util.List;
018import java.util.Map;
019
020import javax.xml.namespace.QName;
021import javax.xml.transform.Result;
022import javax.xml.transform.TransformerConfigurationException;
023import javax.xml.transform.TransformerFactory;
024import javax.xml.transform.dom.DOMResult;
025import javax.xml.transform.sax.SAXTransformerFactory;
026import javax.xml.transform.sax.TransformerHandler;
027import javax.xml.transform.stream.StreamResult;
028
029import org.w3c.dom.Document;
030import org.w3c.dom.Element;
031import org.w3c.dom.NamedNodeMap;
032import org.w3c.dom.Node;
033import org.w3c.dom.NodeList;
034import org.w3c.dom.Text;
035import org.xml.sax.Attributes;
036import org.xml.sax.SAXException;
037import org.xml.sax.helpers.AttributesImpl;
038
039import biweekly.ICalDataType;
040import biweekly.ICalVersion;
041import biweekly.ICalendar;
042import biweekly.component.ICalComponent;
043import biweekly.component.VTimezone;
044import biweekly.io.SkipMeException;
045import biweekly.io.StreamWriter;
046import biweekly.io.scribe.component.ICalComponentScribe;
047import biweekly.io.scribe.property.ICalPropertyScribe;
048import biweekly.parameter.ICalParameters;
049import biweekly.property.ICalProperty;
050import biweekly.property.Version;
051import biweekly.property.Xml;
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 vCards) in a streaming fashion.
086 * </p>
087 * <p>
088 * <b>Example:</b>
089 * 
090 * <pre class="brush:java">
091 * VCard vcard1 = ...
092 * VCard vcard2 = ...
093 * 
094 * File file = new File("vcards.xml");
095 * XCardWriter xcardWriter = new XCardWriter(file);
096 * xcardWriter.write(vcard1);
097 * xcardWriter.write(vcard2);
098 * xcardWriter.close();
099 * </pre>
100 * 
101 * </p>
102 * 
103 * <p>
104 * <b>Changing the timezone settings:</b>
105 * 
106 * <pre class="brush:java">
107 * XCalWriter writer = new XCalWriter(...);
108 * 
109 * //format all date/time values in a specific timezone instead of UTC
110 * //note: this makes an HTTP call to the "tzurl.org" website
111 * writer.getTimezoneInfo().setDefaultTimeZone(TimeZone.getDefault());
112 * 
113 * //format the value of a particular date/time property in a specific timezone instead of UTC
114 * //note: this makes an HTTP call to the "tzurl.org" website
115 * DateStart dtstart = ...
116 * writer.getTimezoneInfo().setTimeZone(dtstart, TimeZone.getDefault());
117 * 
118 * //generate Outlook-friendly VTIMEZONE components:
119 * writer.getTimezoneInfo().setGenerator(new TzUrlDotOrgGenerator(true));
120 * </pre>
121 * 
122 * </p>
123 * 
124 * @author Michael Angstadt
125 * @see <a href="http://tools.ietf.org/html/rfc6351">RFC 6351</a>
126 */
127public class XCalWriter extends StreamWriter {
128        //how to use SAX to write XML: http://stackoverflow.com/questions/4898590/generating-xml-using-sax-and-java
129        private final Document DOC = XmlUtils.createDocument();
130
131        /**
132         * Defines the names of the XML elements that are used to hold each
133         * parameter's value.
134         */
135        private final Map<String, ICalDataType> parameterDataTypes = new HashMap<String, ICalDataType>();
136        {
137                registerParameterDataType(ICalParameters.CN, ICalDataType.TEXT);
138                registerParameterDataType(ICalParameters.ALTREP, ICalDataType.URI);
139                registerParameterDataType(ICalParameters.CUTYPE, ICalDataType.TEXT);
140                registerParameterDataType(ICalParameters.DELEGATED_FROM, ICalDataType.CAL_ADDRESS);
141                registerParameterDataType(ICalParameters.DELEGATED_TO, ICalDataType.CAL_ADDRESS);
142                registerParameterDataType(ICalParameters.DIR, ICalDataType.URI);
143                registerParameterDataType(ICalParameters.ENCODING, ICalDataType.TEXT);
144                registerParameterDataType(ICalParameters.FMTTYPE, ICalDataType.TEXT);
145                registerParameterDataType(ICalParameters.FBTYPE, ICalDataType.TEXT);
146                registerParameterDataType(ICalParameters.LANGUAGE, ICalDataType.TEXT);
147                registerParameterDataType(ICalParameters.MEMBER, ICalDataType.CAL_ADDRESS);
148                registerParameterDataType(ICalParameters.PARTSTAT, ICalDataType.TEXT);
149                registerParameterDataType(ICalParameters.RANGE, ICalDataType.TEXT);
150                registerParameterDataType(ICalParameters.RELATED, ICalDataType.TEXT);
151                registerParameterDataType(ICalParameters.RELTYPE, ICalDataType.TEXT);
152                registerParameterDataType(ICalParameters.ROLE, ICalDataType.TEXT);
153                registerParameterDataType(ICalParameters.RSVP, ICalDataType.BOOLEAN);
154                registerParameterDataType(ICalParameters.SENT_BY, ICalDataType.CAL_ADDRESS);
155                registerParameterDataType(ICalParameters.TZID, ICalDataType.TEXT);
156        }
157
158        private final Writer writer;
159        private final ICalVersion targetVersion = ICalVersion.V2_0;
160        private final TransformerHandler handler;
161        private final String indent;
162        private final boolean icalendarElementExists;
163        private int level = 0;
164        private boolean textNodeJustPrinted = false, started = false;
165
166        /**
167         * Creates an xCard writer (UTF-8 encoding will be used).
168         * @param out the output stream to write the xCards to
169         */
170        public XCalWriter(OutputStream out) {
171                this(utf8Writer(out));
172        }
173
174        /**
175         * Creates an xCard writer (UTF-8 encoding will be used).
176         * @param out the output stream to write the xCards to
177         * @param indent the indentation string to use for pretty printing (e.g.
178         * "\t") or null not to pretty print
179         */
180        public XCalWriter(OutputStream out, String indent) {
181                this(utf8Writer(out), indent);
182        }
183
184        /**
185         * Creates an xCard writer (UTF-8 encoding will be used).
186         * @param file the file to write the xCards to
187         * @throws IOException if there's a problem opening the file
188         */
189        public XCalWriter(File file) throws IOException {
190                this(utf8Writer(file));
191        }
192
193        /**
194         * Creates an xCard writer (UTF-8 encoding will be used).
195         * @param file the file to write the xCards to
196         * @param indent the indentation string to use for pretty printing (e.g.
197         * "\t") or null not to pretty print
198         * @throws IOException if there's a problem opening the file
199         */
200        public XCalWriter(File file, String indent) throws IOException {
201                this(utf8Writer(file), indent);
202        }
203
204        /**
205         * Creates an xCard writer.
206         * @param writer the writer to write to
207         */
208        public XCalWriter(Writer writer) {
209                this(writer, null);
210        }
211
212        /**
213         * Creates an xCard writer.
214         * @param writer the writer to write to
215         * @param indent the indentation string to use for pretty printing (e.g.
216         * "\t") or null not to pretty print
217         */
218        public XCalWriter(Writer writer, String indent) {
219                this(writer, indent, null);
220        }
221
222        /**
223         * Creates an xCard writer.
224         * @param parent the DOM node to add child elements to
225         */
226        public XCalWriter(Node parent) {
227                this(null, null, parent);
228        }
229
230        private XCalWriter(Writer writer, String indent, Node parent) {
231                this.writer = writer;
232                this.indent = indent;
233
234                if (parent instanceof Document) {
235                        Node root = parent.getFirstChild();
236                        if (root != null) {
237                                parent = root;
238                        }
239                }
240                this.icalendarElementExists = isICalendarElement(parent);
241
242                try {
243                        SAXTransformerFactory factory = (SAXTransformerFactory) TransformerFactory.newInstance();
244                        handler = factory.newTransformerHandler();
245                } catch (TransformerConfigurationException e) {
246                        throw new RuntimeException(e);
247                }
248
249                Result result = (writer == null) ? new DOMResult(parent) : new StreamResult(writer);
250                handler.setResult(result);
251        }
252
253        private boolean isICalendarElement(Node node) {
254                if (node == null) {
255                        return false;
256                }
257
258                if (!(node instanceof Element)) {
259                        return false;
260                }
261
262                return ICALENDAR.getNamespaceURI().equals(node.getNamespaceURI()) && ICALENDAR.getLocalPart().equals(node.getLocalName());
263        }
264
265        /**
266         * Registers the data type of an experimental parameter. Experimental
267         * parameters use the "unknown" data type by default.
268         * @param parameterName the parameter name (e.g. "x-foo")
269         * @param dataType the data type or null to remove
270         */
271        public void registerParameterDataType(String parameterName, ICalDataType dataType) {
272                parameterName = parameterName.toLowerCase();
273                if (dataType == null) {
274                        parameterDataTypes.remove(parameterName);
275                } else {
276                        parameterDataTypes.put(parameterName, dataType);
277                }
278        }
279
280        @Override
281        protected void _write(ICalendar ical) throws IOException {
282                try {
283                        if (!started) {
284                                handler.startDocument();
285
286                                if (!icalendarElementExists) {
287                                        //don't output a <icalendar> element if the parent is a <icalendar> element
288                                        start(ICALENDAR);
289                                        level++;
290                                }
291
292                                started = true;
293                        }
294
295                        write((ICalComponent) ical);
296                } catch (SAXException e) {
297                        throw new IOException(e);
298                }
299        }
300
301        @Override
302        protected ICalVersion getTargetVersion() {
303                return targetVersion;
304        }
305
306        @SuppressWarnings({ "rawtypes", "unchecked" })
307        private void write(ICalComponent component) throws SAXException {
308                ICalComponentScribe scribe = index.getComponentScribe(component);
309                String name = scribe.getComponentName().toLowerCase();
310
311                start(name);
312                level++;
313
314                List properties = scribe.getProperties(component);
315                if (component instanceof ICalendar && component.getProperty(Version.class) == null) {
316                        properties.add(0, new Version(targetVersion));
317                }
318
319                if (!properties.isEmpty()) {
320                        start(PROPERTIES);
321                        level++;
322
323                        for (Object propertyObj : properties) {
324                                context.setParent(component); //set parent here incase a scribe resets the parent
325                                ICalProperty property = (ICalProperty) propertyObj;
326                                write(property);
327                        }
328
329                        level--;
330                        end(PROPERTIES);
331                }
332
333                Collection subComponents = scribe.getComponents(component);
334                if (component instanceof ICalendar) {
335                        //add the VTIMEZONE components that were auto-generated by TimezoneOptions
336                        Collection<VTimezone> tzs = tzinfo.getComponents();
337                        for (VTimezone tz : tzs) {
338                                if (!subComponents.contains(tz)) {
339                                        subComponents.add(tz);
340                                }
341                        }
342                }
343                if (!subComponents.isEmpty()) {
344                        start(COMPONENTS);
345                        level++;
346
347                        for (Object subComponentObj : subComponents) {
348                                ICalComponent subComponent = (ICalComponent) subComponentObj;
349                                write(subComponent);
350                        }
351
352                        level--;
353                        end(COMPONENTS);
354                }
355
356                level--;
357                end(name);
358        }
359
360        @SuppressWarnings({ "rawtypes", "unchecked" })
361        private void write(ICalProperty property) throws SAXException {
362                ICalPropertyScribe scribe = index.getPropertyScribe(property);
363                ICalParameters parameters = scribe.prepareParameters(property, context);
364
365                //get the property element to write
366                Element propertyElement;
367                if (property instanceof Xml) {
368                        Xml xml = (Xml) property;
369                        Document value = xml.getValue();
370                        if (value == null) {
371                                return;
372                        }
373                        propertyElement = XmlUtils.getRootElement(value);
374                } else {
375                        QName qname = scribe.getQName();
376                        propertyElement = DOC.createElementNS(qname.getNamespaceURI(), qname.getLocalPart());
377                        try {
378                                scribe.writeXml(property, propertyElement, context);
379                        } catch (SkipMeException e) {
380                                return;
381                        }
382                }
383
384                start(propertyElement);
385                level++;
386
387                write(parameters);
388                write(propertyElement);
389
390                level--;
391                end(propertyElement);
392        }
393
394        private void write(Element propertyElement) throws SAXException {
395                NodeList children = propertyElement.getChildNodes();
396                for (int i = 0; i < children.getLength(); i++) {
397                        Node child = children.item(i);
398
399                        if (child instanceof Element) {
400                                Element element = (Element) child;
401
402                                if (element.hasChildNodes()) {
403                                        start(element);
404                                        level++;
405
406                                        write(element);
407
408                                        level--;
409                                        end(element);
410                                } else {
411                                        //make childless elements appear as "<foo />" instead of "<foo></foo>"
412                                        childless(element);
413                                }
414
415                                continue;
416                        }
417
418                        if (child instanceof Text) {
419                                Text text = (Text) child;
420                                text(text.getTextContent());
421                                continue;
422                        }
423                }
424        }
425
426        private void write(ICalParameters parameters) throws SAXException {
427                if (parameters.isEmpty()) {
428                        return;
429                }
430
431                start(PARAMETERS);
432                level++;
433
434                for (Map.Entry<String, List<String>> parameter : parameters) {
435                        String parameterName = parameter.getKey().toLowerCase();
436                        start(parameterName);
437                        level++;
438
439                        for (String parameterValue : parameter.getValue()) {
440                                ICalDataType dataType = parameterDataTypes.get(parameterName);
441                                String dataTypeElementName = (dataType == null) ? "unknown" : dataType.getName().toLowerCase();
442
443                                start(dataTypeElementName);
444                                text(parameterValue);
445                                end(dataTypeElementName);
446                        }
447
448                        level--;
449                        end(parameterName);
450                }
451
452                level--;
453                end(PARAMETERS);
454        }
455
456        private void indent() throws SAXException {
457                if (indent == null) {
458                        return;
459                }
460
461                StringBuilder sb = new StringBuilder(NEWLINE);
462                for (int i = 0; i < level; i++) {
463                        sb.append(indent);
464                }
465
466                String str = sb.toString();
467                handler.ignorableWhitespace(str.toCharArray(), 0, str.length());
468        }
469
470        private void childless(Element element) throws SAXException {
471                Attributes attributes = getElementAttributes(element);
472                indent();
473                handler.startElement(element.getNamespaceURI(), "", element.getLocalName(), attributes);
474                handler.endElement(element.getNamespaceURI(), "", element.getLocalName());
475        }
476
477        private void start(Element element) throws SAXException {
478                Attributes attributes = getElementAttributes(element);
479                start(element.getNamespaceURI(), element.getLocalName(), attributes);
480        }
481
482        private void start(String element) throws SAXException {
483                start(element, null);
484        }
485
486        private void start(QName qname) throws SAXException {
487                start(qname, null);
488        }
489
490        private void start(QName qname, Attributes attributes) throws SAXException {
491                start(qname.getNamespaceURI(), qname.getLocalPart(), attributes);
492        }
493
494        private void start(String element, Attributes attributes) throws SAXException {
495                start(XCAL_NS, element, attributes);
496        }
497
498        private void start(String namespace, String element, Attributes attributes) throws SAXException {
499                indent();
500                handler.startElement(namespace, "", element, attributes);
501        }
502
503        private void end(Element element) throws SAXException {
504                end(element.getNamespaceURI(), element.getLocalName());
505        }
506
507        private void end(String element) throws SAXException {
508                end(XCAL_NS, element);
509        }
510
511        private void end(QName qname) throws SAXException {
512                end(qname.getNamespaceURI(), qname.getLocalPart());
513        }
514
515        private void end(String namespace, String element) throws SAXException {
516                if (!textNodeJustPrinted) {
517                        indent();
518                }
519
520                handler.endElement(namespace, "", element);
521                textNodeJustPrinted = false;
522        }
523
524        private void text(String text) throws SAXException {
525                handler.characters(text.toCharArray(), 0, text.length());
526                textNodeJustPrinted = true;
527        }
528
529        private Attributes getElementAttributes(Element element) {
530                AttributesImpl attributes = new AttributesImpl();
531                NamedNodeMap attributeNodes = element.getAttributes();
532                for (int i = 0; i < attributeNodes.getLength(); i++) {
533                        Node node = attributeNodes.item(i);
534                        attributes.addAttribute(node.getNamespaceURI(), "", node.getLocalName(), "", node.getNodeValue());
535                }
536                return attributes;
537        }
538
539        /**
540         * Terminates the XML document and closes the output stream.
541         */
542        public void close() throws IOException {
543                try {
544                        if (!started) {
545                                handler.startDocument();
546
547                                if (!icalendarElementExists) {
548                                        //don't output a <icalendar> element if the parent is a <icalendar> element
549                                        start(ICALENDAR);
550                                        level++;
551                                }
552                        }
553
554                        if (!icalendarElementExists) {
555                                level--;
556                                end(ICALENDAR);
557                        }
558                        handler.endDocument();
559                } catch (SAXException e) {
560                        throw new IOException(e);
561                }
562
563                if (writer != null) {
564                        writer.close();
565                }
566        }
567}