001package ezvcard.io.text;
002
003import static com.github.mangstadt.vinnie.Utils.escapeNewlines;
004
005import java.io.Flushable;
006import java.io.IOException;
007import java.io.OutputStream;
008import java.io.OutputStreamWriter;
009import java.io.StringWriter;
010import java.io.Writer;
011import java.nio.charset.Charset;
012import java.nio.charset.StandardCharsets;
013import java.nio.file.Files;
014import java.nio.file.Path;
015import java.nio.file.StandardOpenOption;
016import java.util.ArrayList;
017import java.util.List;
018
019import com.github.mangstadt.vinnie.VObjectParameters;
020import com.github.mangstadt.vinnie.io.VObjectPropertyValues;
021import com.github.mangstadt.vinnie.io.VObjectWriter;
022
023import ezvcard.VCard;
024import ezvcard.VCardDataType;
025import ezvcard.VCardVersion;
026import ezvcard.io.EmbeddedVCardException;
027import ezvcard.io.SkipMeException;
028import ezvcard.io.StreamWriter;
029import ezvcard.io.scribe.VCardPropertyScribe;
030import ezvcard.parameter.Encoding;
031import ezvcard.parameter.VCardParameters;
032import ezvcard.property.Address;
033import ezvcard.property.BinaryProperty;
034import ezvcard.property.StructuredName;
035import ezvcard.property.VCardProperty;
036
037/*
038 Copyright (c) 2012-2023, Michael Angstadt
039 All rights reserved.
040
041 Redistribution and use in source and binary forms, with or without
042 modification, are permitted provided that the following conditions are met: 
043
044 1. Redistributions of source code must retain the above copyright notice, this
045 list of conditions and the following disclaimer. 
046 2. Redistributions in binary form must reproduce the above copyright notice,
047 this list of conditions and the following disclaimer in the documentation
048 and/or other materials provided with the distribution. 
049
050 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
051 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
052 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
053 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
054 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
055 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
056 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
057 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
058 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
059 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
060
061 The views and conclusions contained in the software and documentation are those
062 of the authors and should not be interpreted as representing official policies, 
063 either expressed or implied, of the FreeBSD Project.
064 */
065
066/**
067 * <p>
068 * Writes {@link VCard} objects to a plain-text vCard data stream.
069 * </p>
070 * <p>
071 * <b>Example:</b>
072 * </p>
073 * 
074 * <pre class="brush:java">
075 * VCard vcard1 = ...
076 * VCard vcard2 = ...
077 * Path file = Paths.get("vcard.vcf");
078 * try (VCardWriter writer = new VCardWriter(file, VCardVersion.V3_0)) {
079 *   writer.write(vcard1);
080 *   writer.write(vcard2);
081 * }
082 * </pre>
083 * 
084 * <p>
085 * <b>Changing the line folding settings:</b>
086 * </p>
087 * 
088 * <pre class="brush:java">
089 * VCardWriter writer = new VCardWriter(...);
090 * 
091 * //disable line folding
092 * writer.getVObjectWriter().getFoldedLineWriter().setLineLength(null);
093 * 
094 * //change line length
095 * writer.getVObjectWriter().getFoldedLineWriter().setLineLength(50);
096 * 
097 * //change folded line indent string
098 * writer.getVObjectWriter().getFoldedLineWriter().setIndent("\t");
099 * 
100 * </pre>
101 * @author Michael Angstadt
102 * @see <a href="http://www.imc.org/pdi/vcard-21.rtf">vCard 2.1</a>
103 * @see <a href="http://tools.ietf.org/html/rfc2426">RFC 2426 (3.0)</a>
104 * @see <a href="http://tools.ietf.org/html/rfc6350">RFC 6350 (4.0)</a>
105 */
106public class VCardWriter extends StreamWriter implements Flushable {
107        private final VObjectWriter writer;
108        private final List<Boolean> prodIdStack = new ArrayList<>();
109        private VCardVersion targetVersion;
110        private TargetApplication targetApplication;
111        private Boolean includeTrailingSemicolons;
112
113        /**
114         * @param out the output stream to write to
115         * @param targetVersion the version that the vCards should conform to (if
116         * set to "4.0", vCards will be written in UTF-8 encoding)
117         */
118        public VCardWriter(OutputStream out, VCardVersion targetVersion) {
119                this(new OutputStreamWriter(out, (targetVersion == VCardVersion.V4_0) ? StandardCharsets.UTF_8 : Charset.defaultCharset()), targetVersion);
120        }
121
122        /**
123         * @param file the file to write to
124         * @param targetVersion the version that the vCards should conform to (if
125         * set to "4.0", vCards will be written in UTF-8 encoding)
126         * @throws IOException if there's a problem opening the file
127         */
128        public VCardWriter(Path file, VCardVersion targetVersion) throws IOException {
129                this(file, false, targetVersion);
130        }
131
132        /**
133         * @param file the file to write to
134         * @param append true to append to the end of the file, false to overwrite
135         * it
136         * @param targetVersion the version that the vCards should conform to (if
137         * set to "4.0", vCards will be written in UTF-8 encoding)
138         * @throws IOException if there's a problem opening the file
139         */
140        public VCardWriter(Path file, boolean append, VCardVersion targetVersion) throws IOException {
141                //@formatter:off
142                this(
143                        Files.newBufferedWriter(
144                                file,
145                                (targetVersion == VCardVersion.V4_0) ? StandardCharsets.UTF_8 : Charset.defaultCharset(),
146                                append ? StandardOpenOption.APPEND : StandardOpenOption.TRUNCATE_EXISTING
147                        ),
148                        targetVersion
149                );
150                //@formatter:on
151        }
152
153        /**
154         * @param writer the writer to write to
155         * @param targetVersion the version that the vCards should conform to
156         */
157        public VCardWriter(Writer writer, VCardVersion targetVersion) {
158                this.writer = new VObjectWriter(writer, targetVersion.getSyntaxStyle());
159                this.targetVersion = targetVersion;
160        }
161
162        /**
163         * Gets the writer that this object uses to write data to the output stream.
164         * @return the writer
165         */
166        public VObjectWriter getVObjectWriter() {
167                return writer;
168        }
169
170        /**
171         * Gets the version that the vCards should adhere to.
172         * @return the vCard version
173         */
174        @Override
175        public VCardVersion getTargetVersion() {
176                return targetVersion;
177        }
178
179        /**
180         * Sets the version that the vCards should adhere to.
181         * @param targetVersion the vCard version
182         */
183        public void setTargetVersion(VCardVersion targetVersion) {
184                writer.setSyntaxStyle(targetVersion.getSyntaxStyle());
185                this.targetVersion = targetVersion;
186        }
187
188        /**
189         * <p>
190         * Gets the application that the vCards will be targeted for.
191         * </p>
192         * <p>
193         * Some vCard consumers do not completely adhere to the vCard specifications
194         * and require their vCards to be formatted in a specific way. See the
195         * {@link TargetApplication} class for a list of these applications.
196         * </p>
197         * @return the target application or null if the vCards do not be given any
198         * special processing (defaults to null)
199         */
200        public TargetApplication getTargetApplication() {
201                return targetApplication;
202        }
203
204        /**
205         * <p>
206         * Sets the application that the vCards will be targeted for.
207         * </p>
208         * <p>
209         * Some vCard consumers do not completely adhere to the vCard specifications
210         * and require their vCards to be formatted in a specific way. See the
211         * {@link TargetApplication} class for a list of these applications.
212         * </p>
213         * @param targetApplication the target application or null if the vCards do
214         * not require any special processing (defaults to null)
215         */
216        public void setTargetApplication(TargetApplication targetApplication) {
217                this.targetApplication = targetApplication;
218        }
219
220        /**
221         * <p>
222         * Gets whether this writer will include trailing semicolon delimiters for
223         * structured property values whose list of values end with null or empty
224         * values. Examples of properties that use structured values are
225         * {@link StructuredName} and {@link Address}.
226         * </p>
227         * <p>
228         * This setting exists for compatibility reasons and should not make a
229         * difference to consumers that correctly implement the vCard grammar.
230         * </p>
231         * @return true to include the trailing semicolons, false not to, null to
232         * use the default behavior (defaults to false for vCard versions 2.1 and
233         * 3.0 and true for vCard version 4.0)
234         * @see <a href="https://github.com/mangstadt/ez-vcard/issues/57">Issue
235         * 57</a>
236         */
237        public Boolean isIncludeTrailingSemicolons() {
238                return includeTrailingSemicolons;
239        }
240
241        /**
242         * <p>
243         * Sets whether to include trailing semicolon delimiters for structured
244         * property values whose list of values end with null or empty values.
245         * Examples of properties that use structured values are
246         * {@link StructuredName} and {@link Address}.
247         * </p>
248         * <p>
249         * This setting exists for compatibility reasons and should not make a
250         * difference to consumers that correctly implement the vCard grammar.
251         * </p>
252         * @param include true to include the trailing semicolons, false not to,
253         * null to use the default behavior (defaults to false for vCard versions
254         * 2.1 and 3.0 and true for vCard version 4.0)
255         * @see <a href="https://github.com/mangstadt/ez-vcard/issues/57">Issue
256         * 57</a>
257         */
258        public void setIncludeTrailingSemicolons(Boolean include) {
259                includeTrailingSemicolons = include;
260        }
261
262        /**
263         * <p>
264         * Gets whether the writer will apply circumflex accent encoding on
265         * parameter values (disabled by default). This escaping mechanism allows
266         * for newlines and double quotes to be included in parameter values. It is
267         * only supported by vCard versions 3.0 and 4.0.
268         * </p>
269         * 
270         * <p>
271         * Note that this encoding mechanism is defined separately from the vCard
272         * specification and may not be supported by the consumer of the vCard.
273         * </p>
274         * @return true if circumflex accent encoding is enabled, false if not
275         * @see VObjectWriter#isCaretEncodingEnabled()
276         */
277        public boolean isCaretEncodingEnabled() {
278                return writer.isCaretEncodingEnabled();
279        }
280
281        /**
282         * <p>
283         * Sets whether the writer will apply circumflex accent encoding on
284         * parameter values (disabled by default). This escaping mechanism allows
285         * for newlines and double quotes to be included in parameter values. It is
286         * only supported by vCard versions 3.0 and 4.0.
287         * </p>
288         * 
289         * <p>
290         * Note that this encoding mechanism is defined separately from the vCard
291         * specification and may not be supported by the consumer of the vCard.
292         * </p>
293         * @param enable true to use circumflex accent encoding, false not to
294         * @see VObjectWriter#setCaretEncodingEnabled(boolean)
295         */
296        public void setCaretEncodingEnabled(boolean enable) {
297                writer.setCaretEncodingEnabled(enable);
298        }
299
300        @Override
301        @SuppressWarnings({ "rawtypes", "unchecked" })
302        protected void _write(VCard vcard, List<VCardProperty> propertiesToAdd) throws IOException {
303                VCardVersion targetVersion = getTargetVersion();
304                TargetApplication targetApplication = getTargetApplication();
305
306                Boolean includeTrailingSemicolons = this.includeTrailingSemicolons;
307                if (includeTrailingSemicolons == null) {
308                        includeTrailingSemicolons = (targetVersion == VCardVersion.V4_0);
309                }
310
311                WriteContext context = new WriteContext(targetVersion, targetApplication, includeTrailingSemicolons);
312
313                writer.writeBeginComponent("VCARD");
314                writer.writeVersion(targetVersion.getVersion());
315
316                for (VCardProperty property : propertiesToAdd) {
317                        VCardPropertyScribe scribe = index.getPropertyScribe(property);
318
319                        String value = null;
320                        VCard nestedVCard = null;
321                        try {
322                                value = scribe.writeText(property, context);
323                        } catch (SkipMeException e) {
324                                continue;
325                        } catch (EmbeddedVCardException e) {
326                                nestedVCard = e.getVCard();
327                        }
328
329                        VCardParameters parameters = scribe.prepareParameters(property, targetVersion, vcard);
330
331                        if (nestedVCard != null) {
332                                writeNestedVCard(nestedVCard, property, scribe, parameters, value);
333                                continue;
334                        }
335
336                        handleValueParameter(property, scribe, parameters);
337                        handleLabelParameter(property, parameters);
338                        handleQuotedPrintableEncodingParameter(property, parameters);
339
340                        writer.writeProperty(property.getGroup(), scribe.getPropertyName(), new VObjectParameters(parameters.getMap()), value);
341
342                        fixBinaryPropertyForOutlook(property);
343                }
344
345                writer.writeEndComponent("VCARD");
346        }
347
348        @SuppressWarnings("rawtypes")
349        private void writeNestedVCard(VCard nestedVCard, VCardProperty property, VCardPropertyScribe scribe, VCardParameters parameters, String value) throws IOException {
350                if (targetVersion == VCardVersion.V2_1) {
351                        //write a nested vCard (2.1 style)
352                        writer.writeProperty(property.getGroup(), scribe.getPropertyName(), new VObjectParameters(parameters.getMap()), value);
353                        prodIdStack.add(addProdId);
354                        addProdId = false;
355                        write(nestedVCard);
356                        addProdId = prodIdStack.remove(prodIdStack.size() - 1);
357                } else {
358                        //write an embedded vCard (3.0 style)
359                        StringWriter sw = new StringWriter();
360                        try (VCardWriter agentWriter = new VCardWriter(sw, targetVersion)) {
361                                agentWriter.getVObjectWriter().getFoldedLineWriter().setLineLength(null);
362                                agentWriter.setAddProdId(false);
363                                agentWriter.setCaretEncodingEnabled(isCaretEncodingEnabled());
364                                agentWriter.setIncludeTrailingSemicolons(this.includeTrailingSemicolons);
365                                agentWriter.setScribeIndex(index);
366                                agentWriter.setTargetApplication(targetApplication);
367                                agentWriter.setVersionStrict(versionStrict);
368                                agentWriter.write(nestedVCard);
369                        } catch (IOException ignore) {
370                                //should never be thrown because we're writing to a string
371                        }
372
373                        String vcardStr = sw.toString();
374                        vcardStr = VObjectPropertyValues.escape(vcardStr);
375                        writer.writeProperty(property.getGroup(), scribe.getPropertyName(), new VObjectParameters(parameters.getMap()), vcardStr);
376                }
377        }
378
379        /**
380         * <p>
381         * Sets the property's VALUE parameter. This method only adds a VALUE
382         * parameter if all the following conditions are met:
383         * </p>
384         * <ol>
385         * <li>The data type is NOT "unknown"</li>
386         * <li>The data type is different from the property's default data type</li>
387         * <li>The data type does not fall under the "date/time special case" (see
388         * {@link #isDateTimeValueParameterSpecialCase})</li>
389         * </ol>
390         * @param property the property
391         * @param scribe the property scribe
392         * @param parameters the property parameters
393         */
394        @SuppressWarnings({ "rawtypes", "unchecked" })
395        private void handleValueParameter(VCardProperty property, VCardPropertyScribe scribe, VCardParameters parameters) {
396                VCardDataType dataType = scribe.dataType(property, targetVersion);
397                if (dataType == null) {
398                        return;
399                }
400
401                VCardDataType defaultDataType = scribe.defaultDataType(targetVersion);
402                if (dataType == defaultDataType) {
403                        return;
404                }
405
406                if (isDateTimeValueParameterSpecialCase(defaultDataType, dataType)) {
407                        return;
408                }
409
410                parameters.setValue(dataType);
411        }
412
413        /**
414         * <p>
415         * Escapes newline sequences in the LABEL parameter of {@link Address}
416         * properties. Newlines cannot normally be escaped in parameter values.
417         * </p>
418         * <p>
419         * Only version 4.0 allows this (and only version 4.0 defines a LABEL
420         * parameter), but this method does this for all versions for compatibility.
421         * </p>
422         * @param property the property
423         * @param parameters the property parameters
424         */
425        private void handleLabelParameter(VCardProperty property, VCardParameters parameters) {
426                if (!(property instanceof Address)) {
427                        return;
428                }
429
430                String label = parameters.getLabel();
431                if (label == null) {
432                        return;
433                }
434
435                label = escapeNewlines(label);
436                parameters.setLabel(label);
437        }
438
439        /**
440         * Disables quoted-printable encoding on the given property if the target
441         * vCard version does not support this encoding scheme.
442         * @param property the property
443         * @param parameters the property parameters
444         */
445        private void handleQuotedPrintableEncodingParameter(VCardProperty property, VCardParameters parameters) {
446                if (targetVersion == VCardVersion.V2_1) {
447                        return;
448                }
449
450                if (parameters.getEncoding() == Encoding.QUOTED_PRINTABLE) {
451                        parameters.setEncoding(null);
452                        parameters.setCharset(null);
453                }
454        }
455
456        /**
457         * @see TargetApplication#OUTLOOK
458         */
459        private void fixBinaryPropertyForOutlook(VCardProperty property) throws IOException {
460                if (targetApplication != TargetApplication.OUTLOOK) {
461                        return;
462                }
463
464                if (getTargetVersion() == VCardVersion.V4_0) {
465                        //only do this for 2.1 and 3.0 vCards
466                        return;
467                }
468
469                if (!(property instanceof BinaryProperty)) {
470                        //property does not have binary data
471                        return;
472                }
473
474                BinaryProperty<?> binaryProperty = (BinaryProperty<?>) property;
475                if (binaryProperty.getData() == null) {
476                        //property value is not base64-encoded
477                        return;
478                }
479
480                writer.getFoldedLineWriter().writeln();
481        }
482
483        /**
484         * Determines if the given default data type is "date-and-or-time" and the
485         * given data type is time-based. Properties that meet this criteria should
486         * NOT be given a VALUE parameter.
487         * @param defaultDataType the property's default data type
488         * @param dataType the current property instance's data type
489         * @return true if the default data type is "date-and-or-time" and the data
490         * type is time-based, false otherwise
491         */
492        private boolean isDateTimeValueParameterSpecialCase(VCardDataType defaultDataType, VCardDataType dataType) {
493                //@formatter:off
494                return
495                defaultDataType == VCardDataType.DATE_AND_OR_TIME &&
496                (
497                        dataType == VCardDataType.DATE ||
498                        dataType == VCardDataType.DATE_TIME ||
499                        dataType == VCardDataType.TIME
500                );
501                //@formatter:on
502        }
503
504        /**
505         * Flushes the output stream.
506         * @throws IOException if there's a problem flushing the output stream
507         */
508        public void flush() throws IOException {
509                writer.flush();
510        }
511
512        /**
513         * Closes the output stream.
514         * @throws IOException if there's a problem closing the output stream
515         */
516        public void close() throws IOException {
517                writer.close();
518        }
519}