001package ezvcard.io.xml;
002
003import static ezvcard.io.xml.XCardQNames.GROUP;
004import static ezvcard.io.xml.XCardQNames.PARAMETERS;
005import static ezvcard.io.xml.XCardQNames.VCARD;
006import static ezvcard.io.xml.XCardQNames.VCARDS;
007
008import java.io.IOException;
009import java.io.OutputStream;
010import java.io.OutputStreamWriter;
011import java.io.Writer;
012import java.nio.charset.StandardCharsets;
013import java.nio.file.Files;
014import java.nio.file.Path;
015import java.util.Collections;
016import java.util.List;
017import java.util.Map;
018
019import javax.xml.namespace.QName;
020import javax.xml.transform.Result;
021import javax.xml.transform.Transformer;
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 ezvcard.VCard;
040import ezvcard.VCardDataType;
041import ezvcard.io.EmbeddedVCardException;
042import ezvcard.io.SkipMeException;
043import ezvcard.io.scribe.VCardPropertyScribe;
044import ezvcard.parameter.VCardParameters;
045import ezvcard.property.VCardProperty;
046import ezvcard.property.Xml;
047import ezvcard.util.ListMultimap;
048import ezvcard.util.XmlUtils;
049
050/*
051 Copyright (c) 2012-2023, Michael Angstadt
052 All rights reserved.
053
054 Redistribution and use in source and binary forms, with or without
055 modification, are permitted provided that the following conditions are met: 
056
057 1. Redistributions of source code must retain the above copyright notice, this
058 list of conditions and the following disclaimer. 
059 2. Redistributions in binary form must reproduce the above copyright notice,
060 this list of conditions and the following disclaimer in the documentation
061 and/or other materials provided with the distribution. 
062
063 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
064 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
065 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
066 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
067 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
068 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
069 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
070 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
071 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
072 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
073
074 The views and conclusions contained in the software and documentation are those
075 of the authors and should not be interpreted as representing official policies, 
076 either expressed or implied, of the FreeBSD Project.
077 */
078
079/**
080 * <p>
081 * Writes xCards (XML-encoded vCards) in a streaming fashion.
082 * </p>
083 * <p>
084 * <b>Example:</b>
085 * </p>
086 * 
087 * <pre class="brush:java">
088 * VCard vcard1 = ...
089 * VCard vcard2 = ...
090 * Path file = Paths.get("vcards.xml");
091 * try (XCardWriter writer = new XCardWriter(file)) {
092 *   writer.write(vcard1);
093 *   writer.write(vcard2);
094 * }
095 * </pre>
096 * @author Michael Angstadt
097 * @see <a href="http://tools.ietf.org/html/rfc6351">RFC 6351</a>
098 */
099public class XCardWriter extends XCardWriterBase {
100        //How to use SAX to write XML: http://stackoverflow.com/q/4898590
101
102        private final Document DOC = XmlUtils.createDocument();
103
104        private final Writer writer;
105        private final TransformerHandler handler;
106        private final boolean vcardsElementExists;
107        private boolean started = false;
108
109        /**
110         * @param out the output stream to write to (UTF-8 encoding will be used)
111         */
112        public XCardWriter(OutputStream out) {
113                this(out, (Integer) null);
114        }
115
116        /**
117         * @param out the output stream to write to (UTF-8 encoding will be used)
118         * @param indent the number of indent spaces to use for pretty-printing or
119         * null to disable pretty-printing (disabled by default)
120         */
121        public XCardWriter(OutputStream out, Integer indent) {
122                this(out, indent, null);
123        }
124
125        /**
126         * @param out the output stream to write to (UTF-8 encoding will be used)
127         * @param indent the number of indent spaces to use for pretty-printing or
128         * null to disable pretty-printing (disabled by default)
129         * @param xmlVersion the XML version to use (defaults to "1.0") (Note: Many
130         * JDKs only support 1.0 natively. For XML 1.1 support, add a JAXP library
131         * like <a href=
132         * "http://search.maven.org/#search%7Cgav%7C1%7Cg%3A%22xalan%22%20AND%20a%3A%22xalan%22"
133         * >xalan</a> to your project)
134         */
135        public XCardWriter(OutputStream out, Integer indent, String xmlVersion) {
136                this(out, new XCardOutputProperties(indent, xmlVersion));
137        }
138
139        /**
140         * @param out the output stream to write to (UTF-8 encoding will be used)
141         * @param outputProperties properties to assign to the JAXP transformer (see
142         * {@link Transformer#setOutputProperty})
143         */
144        public XCardWriter(OutputStream out, Map<String, String> outputProperties) {
145                this(new OutputStreamWriter(out, StandardCharsets.UTF_8), outputProperties);
146        }
147
148        /**
149         * @param file the file to write to (UTF-8 encoding will be used)
150         * @throws IOException if there is a problem opening the file
151         */
152        public XCardWriter(Path file) throws IOException {
153                this(file, (Integer) null);
154        }
155
156        /**
157         * @param file the file to write to (UTF-8 encoding will be used)
158         * @param indent the number of indent spaces to use for pretty-printing or
159         * null to disable pretty-printing (disabled by default)
160         * @throws IOException if there is a problem opening the file
161         */
162        public XCardWriter(Path file, Integer indent) throws IOException {
163                this(file, indent, null);
164        }
165
166        /**
167         * @param file the file to write to (UTF-8 encoding will be used)
168         * @param indent the number of indent spaces to use for pretty-printing or
169         * null to disable pretty-printing (disabled by default)
170         * @param xmlVersion the XML version to use (defaults to "1.0") (Note: Many
171         * JDKs only support 1.0 natively. For XML 1.1 support, add a JAXP library
172         * like <a href=
173         * "http://search.maven.org/#search%7Cgav%7C1%7Cg%3A%22xalan%22%20AND%20a%3A%22xalan%22"
174         * >xalan</a> to your project)
175         * @throws IOException if there is a problem opening the file
176         */
177        public XCardWriter(Path file, Integer indent, String xmlVersion) throws IOException {
178                this(file, new XCardOutputProperties(indent, xmlVersion));
179        }
180
181        /**
182         * @param file the file to write to (UTF-8 encoding will be used)
183         * @param outputProperties properties to assign to the JAXP transformer (see
184         * {@link Transformer#setOutputProperty})
185         * @throws IOException if there is a problem opening the file
186         */
187        public XCardWriter(Path file, Map<String, String> outputProperties) throws IOException {
188                this(Files.newBufferedWriter(file, StandardCharsets.UTF_8), outputProperties);
189        }
190
191        /**
192         * @param writer the writer to write to
193         */
194        public XCardWriter(Writer writer) {
195                this(writer, (Integer) null);
196        }
197
198        /**
199         * @param writer the writer to write to
200         * @param indent the number of indent spaces to use for pretty-printing or
201         * null to disable pretty-printing (disabled by default)
202         */
203        public XCardWriter(Writer writer, Integer indent) {
204                this(writer, indent, null);
205        }
206
207        /**
208         * @param writer the writer to write to
209         * @param indent the number of indent spaces to use for pretty-printing or
210         * null to disable pretty-printing (disabled by default)
211         * @param xmlVersion the XML version to use (defaults to "1.0") (Note: Many
212         * JDKs only support 1.0 natively. For XML 1.1 support, add a JAXP library
213         * like <a href=
214         * "http://search.maven.org/#search%7Cgav%7C1%7Cg%3A%22xalan%22%20AND%20a%3A%22xalan%22"
215         * >xalan</a> to your project)
216         */
217        public XCardWriter(Writer writer, Integer indent, String xmlVersion) {
218                this(writer, new XCardOutputProperties(indent, xmlVersion));
219        }
220
221        /**
222         * @param writer the writer to write to
223         * @param outputProperties properties to assign to the JAXP transformer (see
224         * {@link Transformer#setOutputProperty})
225         */
226        public XCardWriter(Writer writer, Map<String, String> outputProperties) {
227                this(writer, null, outputProperties);
228        }
229
230        /**
231         * @param parent the DOM node to add child elements to
232         */
233        public XCardWriter(Node parent) {
234                this(null, parent, Collections.<String, String> emptyMap());
235        }
236
237        private XCardWriter(Writer writer, Node parent, Map<String, String> outputProperties) {
238                this.writer = writer;
239
240                if (parent instanceof Document) {
241                        Node root = ((Document) parent).getDocumentElement();
242                        if (root != null) {
243                                parent = root;
244                        }
245                }
246                this.vcardsElementExists = isVCardsElement(parent);
247
248                try {
249                        SAXTransformerFactory factory = (SAXTransformerFactory) TransformerFactory.newInstance();
250                        handler = factory.newTransformerHandler();
251                } catch (TransformerConfigurationException e) {
252                        throw new RuntimeException(e);
253                }
254
255                Transformer transformer = handler.getTransformer();
256
257                /*
258                 * Using Transformer#setOutputProperties(Properties) doesn't work for
259                 * some reason for setting the number of indentation spaces.
260                 */
261                for (Map.Entry<String, String> entry : outputProperties.entrySet()) {
262                        String key = entry.getKey();
263                        String value = entry.getValue();
264                        transformer.setOutputProperty(key, value);
265                }
266
267                Result result = (writer == null) ? new DOMResult(parent) : new StreamResult(writer);
268                handler.setResult(result);
269        }
270
271        private boolean isVCardsElement(Node node) {
272                if (node == null) {
273                        return false;
274                }
275
276                if (!(node instanceof Element)) {
277                        return false;
278                }
279
280                return XmlUtils.hasQName(node, VCARDS);
281        }
282
283        @Override
284        protected void _write(VCard vcard, List<VCardProperty> properties) throws IOException {
285                try {
286                        if (!started) {
287                                handler.startDocument();
288
289                                if (!vcardsElementExists) {
290                                        //don't output a <vcards> element if the parent is a <vcards> element
291                                        start(VCARDS);
292                                }
293
294                                started = true;
295                        }
296
297                        ListMultimap<String, VCardProperty> propertiesByGroup = new ListMultimap<>(); //group the types by group name (null = no group name)
298                        for (VCardProperty property : properties) {
299                                propertiesByGroup.put(property.getGroup(), property);
300                        }
301
302                        start(VCARD);
303
304                        for (Map.Entry<String, List<VCardProperty>> entry : propertiesByGroup) {
305                                String groupName = entry.getKey();
306                                if (groupName != null) {
307                                        AttributesImpl attr = new AttributesImpl();
308                                        attr.addAttribute(XCardQNames.NAMESPACE, "", "name", "", groupName);
309
310                                        start(GROUP, attr);
311                                }
312
313                                for (VCardProperty property : entry.getValue()) {
314                                        write(property, vcard);
315                                }
316
317                                if (groupName != null) {
318                                        end(GROUP);
319                                }
320                        }
321
322                        end(VCARD);
323                } catch (SAXException e) {
324                        throw new IOException(e);
325                }
326        }
327
328        /**
329         * Terminates the XML document and closes the output stream.
330         */
331        public void close() throws IOException {
332                try {
333                        if (!started) {
334                                handler.startDocument();
335
336                                if (!vcardsElementExists) {
337                                        //don't output a <vcards> element if the parent is a <vcards> element
338                                        start(VCARDS);
339                                }
340                        }
341
342                        if (!vcardsElementExists) {
343                                end(VCARDS);
344                        }
345                        handler.endDocument();
346                } catch (SAXException e) {
347                        throw new IOException(e);
348                }
349
350                if (writer != null) {
351                        writer.close();
352                }
353        }
354
355        @SuppressWarnings({ "rawtypes", "unchecked" })
356        private void write(VCardProperty property, VCard vcard) throws SAXException {
357                VCardPropertyScribe scribe = index.getPropertyScribe(property);
358                VCardParameters parameters = scribe.prepareParameters(property, targetVersion, vcard);
359
360                removeUnsupportedParameters(parameters);
361
362                //get the property element to write
363                Element propertyElement;
364                if (property instanceof Xml) {
365                        Xml xml = (Xml) property;
366                        Document value = xml.getValue();
367                        if (value == null) {
368                                return;
369                        }
370                        propertyElement = value.getDocumentElement();
371                } else {
372                        QName qname = scribe.getQName();
373                        propertyElement = DOC.createElementNS(qname.getNamespaceURI(), qname.getLocalPart());
374                        try {
375                                scribe.writeXml(property, propertyElement);
376                        } catch (SkipMeException e) {
377                                return;
378                        } catch (EmbeddedVCardException e) {
379                                return;
380                        }
381                }
382
383                start(propertyElement);
384
385                write(parameters);
386                write(propertyElement);
387
388                end(propertyElement);
389        }
390
391        private void write(Element propertyElement) throws SAXException {
392                NodeList children = propertyElement.getChildNodes();
393                for (int i = 0; i < children.getLength(); i++) {
394                        Node child = children.item(i);
395
396                        if (child instanceof Element) {
397                                Element element = (Element) child;
398
399                                if (element.hasChildNodes()) {
400                                        start(element);
401                                        write(element);
402                                        end(element);
403                                } else {
404                                        childless(element);
405                                }
406
407                                continue;
408                        }
409
410                        if (child instanceof Text) {
411                                Text text = (Text) child;
412                                text(text.getTextContent());
413                                continue;
414                        }
415                }
416        }
417
418        private void write(VCardParameters parameters) throws SAXException {
419                if (parameters.isEmpty()) {
420                        return;
421                }
422
423                start(PARAMETERS);
424
425                for (Map.Entry<String, List<String>> parameter : parameters) {
426                        String parameterName = parameter.getKey().toLowerCase();
427                        start(parameterName);
428
429                        for (String parameterValue : parameter.getValue()) {
430                                VCardDataType dataType = parameterDataTypes.get(parameterName);
431                                String dataTypeElementName = (dataType == null) ? "unknown" : dataType.getName().toLowerCase();
432
433                                start(dataTypeElementName);
434                                text(parameterValue);
435                                end(dataTypeElementName);
436                        }
437
438                        end(parameterName);
439                }
440
441                end(PARAMETERS);
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 if there is a problem creating the element
449         */
450        private void childless(Element element) throws SAXException {
451                Attributes attributes = getElementAttributes(element);
452                handler.startElement(element.getNamespaceURI(), "", element.getLocalName(), attributes);
453                handler.endElement(element.getNamespaceURI(), "", element.getLocalName());
454        }
455
456        private void start(Element element) throws SAXException {
457                Attributes attributes = getElementAttributes(element);
458                start(element.getNamespaceURI(), element.getLocalName(), attributes);
459        }
460
461        private void start(String element) throws SAXException {
462                start(element, new AttributesImpl());
463        }
464
465        private void start(QName qname) throws SAXException {
466                start(qname, new AttributesImpl());
467        }
468
469        private void start(QName qname, Attributes attributes) throws SAXException {
470                start(qname.getNamespaceURI(), qname.getLocalPart(), attributes);
471        }
472
473        private void start(String element, Attributes attributes) throws SAXException {
474                start(targetVersion.getXmlNamespace(), element, attributes);
475        }
476
477        private void start(String namespace, String element, Attributes attributes) throws SAXException {
478                handler.startElement(namespace, "", element, attributes);
479        }
480
481        private void end(Element element) throws SAXException {
482                end(element.getNamespaceURI(), element.getLocalName());
483        }
484
485        private void end(String element) throws SAXException {
486                end(targetVersion.getXmlNamespace(), element);
487        }
488
489        private void end(QName qname) throws SAXException {
490                end(qname.getNamespaceURI(), qname.getLocalPart());
491        }
492
493        private void end(String namespace, String element) throws SAXException {
494                handler.endElement(namespace, "", element);
495        }
496
497        private void text(String text) throws SAXException {
498                handler.characters(text.toCharArray(), 0, text.length());
499        }
500
501        private Attributes getElementAttributes(Element element) {
502                AttributesImpl attributes = new AttributesImpl();
503                NamedNodeMap attributeNodes = element.getAttributes();
504                for (int i = 0; i < attributeNodes.getLength(); i++) {
505                        Node node = attributeNodes.item(i);
506
507                        String localName = node.getLocalName();
508                        if ("xmlns".equals(localName)) {
509                                continue;
510                        }
511
512                        attributes.addAttribute(node.getNamespaceURI(), "", localName, "", node.getNodeValue());
513                }
514                return attributes;
515        }
516}