001package ezvcard.io.scribe;
002
003import java.time.DateTimeException;
004import java.time.OffsetDateTime;
005import java.time.ZoneId;
006import java.time.ZoneOffset;
007
008import com.github.mangstadt.vinnie.io.VObjectPropertyValues;
009
010import ezvcard.Messages;
011import ezvcard.VCardDataType;
012import ezvcard.VCardVersion;
013import ezvcard.io.CannotParseException;
014import ezvcard.io.ParseContext;
015import ezvcard.io.html.HCardElement;
016import ezvcard.io.json.JCardValue;
017import ezvcard.io.text.WriteContext;
018import ezvcard.io.xml.XCardElement;
019import ezvcard.parameter.VCardParameters;
020import ezvcard.property.Timezone;
021import ezvcard.util.VCardDateFormat;
022
023/*
024 Copyright (c) 2012-2023, Michael Angstadt
025 All rights reserved.
026
027 Redistribution and use in source and binary forms, with or without
028 modification, are permitted provided that the following conditions are met: 
029
030 1. Redistributions of source code must retain the above copyright notice, this
031 list of conditions and the following disclaimer. 
032 2. Redistributions in binary form must reproduce the above copyright notice,
033 this list of conditions and the following disclaimer in the documentation
034 and/or other materials provided with the distribution. 
035
036 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
037 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
038 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
039 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
040 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
041 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
042 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
043 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
044 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
045 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
046 */
047
048/**
049 * Marshals {@link Timezone} properties.
050 * @author Michael Angstadt
051 */
052//@formatter:off
053/* 
054 * Parsing===================
055 * 
056 * vCard 2.1:
057 * Parse as UTC offset.  If invalid, throw CannotParseException.
058 * 
059 * vCard 3.0, hCard:
060 * VALUE=text:                  Treat as text
061 * No VALUE param:              Parse as UTC offset.  If invalid, add warning and treat as text.
062 * 
063 * vCard 4.0, jCard:
064 * VALUE=text:                  Treat as text
065 * VALUE=utc-offset:    Parse as UTC offset.  If invalid, throw CannotParseException
066 * VALUE=uri:                   Not going to support this, as there is no description of what a timezone URI looks like
067 * No VALUE param:              Parse as UTC offset.  If invalid, treat as text
068 * 
069 * xCard:
070 * text | utc-offset    | result
071 * no   | no                    | throw CannotParseException
072 * yes  | no                    | OK
073 * no   | yes                   | OK
074 * no   | invalid               | throw CannotParseException
075 * yes  | yes                   | Parse text
076 * yes  | invalid               | Parse text
077 * 
078 * Writing===================
079 * 
080 * vCard 2.1:
081 * text | utc-offset    | result
082 * no   | no                    | empty string (validation warning)
083 * no   | yes                   | Write UTC offset
084 * yes  | no                    | empty string (validation warning)
085 * yes  | yes                   | Write UTC offset
086 * 
087 * vCard 3.0:
088 * text | utc-offset    | result
089 * no   | no                    | empty string (validation warning)
090 * no   | yes                   | Write UTC offset
091 * yes  | no                    | Write text, add "VALUE=text" parameter
092 * yes  | yes                   | Write UTC offset
093 * 
094 * vCard 4.0, xCard, jCard:
095 * text | utc-offset    | result
096 * no   | no                    | empty string (validation warning)
097 * no   | yes                   | Write UTC offset, add "VALUE=utc-offset" parameter
098 * yes  | no                    | Write text
099 * yes  | yes                   | Write text
100 */
101//@formatter:on
102public class TimezoneScribe extends VCardPropertyScribe<Timezone> {
103        public TimezoneScribe() {
104                super(Timezone.class, "TZ");
105        }
106
107        @Override
108        protected VCardDataType _defaultDataType(VCardVersion version) {
109                switch (version) {
110                case V2_1:
111                case V3_0:
112                        return VCardDataType.UTC_OFFSET;
113                case V4_0:
114                        return VCardDataType.TEXT;
115                }
116                return null;
117        }
118
119        @Override
120        protected VCardDataType _dataType(Timezone property, VCardVersion version) {
121                String text = property.getText();
122                ZoneOffset offset = property.getOffset();
123
124                switch (version) {
125                case V2_1:
126                        return VCardDataType.UTC_OFFSET;
127                case V3_0:
128                        if (offset != null) {
129                                return VCardDataType.UTC_OFFSET;
130                        }
131                        if (text != null) {
132                                return VCardDataType.TEXT;
133                        }
134                        break;
135                case V4_0:
136                        if (text != null) {
137                                return VCardDataType.TEXT;
138                        }
139                        if (offset != null) {
140                                return VCardDataType.UTC_OFFSET;
141                        }
142                        break;
143                }
144
145                return _defaultDataType(version);
146        }
147
148        @Override
149        protected String _writeText(Timezone property, WriteContext context) {
150                String text = property.getText();
151                ZoneOffset offset = property.getOffset();
152
153                switch (context.getVersion()) {
154                case V2_1:
155                        if (offset != null) {
156                                return VCardDateFormat.BASIC.format(offset); //2.1 allows either basic or extended
157                        }
158
159                        if (text != null) {
160                                /*
161                                 * Attempt to find the offset by treating the text as a timezone
162                                 * ID, like "America/New_York", and then computing what the
163                                 * offset for that timezone is at the current moment in time.
164                                 */
165                                try {
166                                        ZoneId zoneId = ZoneId.of(text);
167                                        ZoneOffset offsetNow = OffsetDateTime.now(zoneId).getOffset();
168                                        return VCardDateFormat.BASIC.format(offsetNow);
169                                } catch (DateTimeException ignore) {
170                                        //not a recognized timezone ID
171                                }
172                        }
173                        break;
174                case V3_0:
175                        if (offset != null) {
176                                return VCardDateFormat.EXTENDED.format(offset); //3.0 only allows extended
177                        }
178
179                        if (text != null) {
180                                return VObjectPropertyValues.escape(text);
181                        }
182                        break;
183                case V4_0:
184                        if (text != null) {
185                                return VObjectPropertyValues.escape(text);
186                        }
187
188                        if (offset != null) {
189                                return VCardDateFormat.BASIC.format(offset); //4.0 only allows basic
190                        }
191                        break;
192                }
193
194                return "";
195        }
196
197        @Override
198        protected Timezone _parseText(String value, VCardDataType dataType, VCardParameters parameters, ParseContext context) {
199                value = VObjectPropertyValues.unescape(value);
200                return parse(value, dataType, context);
201        }
202
203        @Override
204        protected void _writeXml(Timezone property, XCardElement parent) {
205                String text = property.getText();
206                if (text != null) {
207                        parent.append(VCardDataType.TEXT, text);
208                        return;
209                }
210
211                ZoneOffset offset = property.getOffset();
212                if (offset != null) {
213                        parent.append(VCardDataType.UTC_OFFSET, VCardDateFormat.BASIC.format(offset));
214                        return;
215                }
216
217                parent.append(VCardDataType.TEXT, "");
218        }
219
220        @Override
221        protected Timezone _parseXml(XCardElement element, VCardParameters parameters, ParseContext context) {
222                String text = element.first(VCardDataType.TEXT);
223                if (text != null) {
224                        return new Timezone(text);
225                }
226
227                String utcOffset = element.first(VCardDataType.UTC_OFFSET);
228                if (utcOffset != null) {
229                        try {
230                                return new Timezone(ZoneOffset.of(utcOffset));
231                        } catch (DateTimeException e) {
232                                throw new CannotParseException(19);
233                        }
234                }
235
236                throw missingXmlElements(VCardDataType.TEXT, VCardDataType.UTC_OFFSET);
237        }
238
239        @Override
240        protected Timezone _parseHtml(HCardElement element, ParseContext context) {
241                return parse(element.value(), null, context);
242        }
243
244        @Override
245        protected JCardValue _writeJson(Timezone property) {
246                String text = property.getText();
247                if (text != null) {
248                        return JCardValue.single(text);
249                }
250
251                ZoneOffset offset = property.getOffset();
252                if (offset != null) {
253                        return JCardValue.single(VCardDateFormat.EXTENDED.format(offset));
254                }
255
256                return JCardValue.single("");
257        }
258
259        @Override
260        protected Timezone _parseJson(JCardValue value, VCardDataType dataType, VCardParameters parameters, ParseContext context) {
261                String valueStr = value.asSingle();
262                return parse(valueStr, dataType, context);
263        }
264
265        private Timezone parse(String value, VCardDataType dataType, ParseContext context) {
266                if (value == null || value.isEmpty()) {
267                        return new Timezone((String) null);
268                }
269
270                switch (context.getVersion()) {
271                case V2_1:
272                        //e.g. "-05:00"
273                        try {
274                                return new Timezone(parse(value));
275                        } catch (IllegalArgumentException e) {
276                                throw new CannotParseException(19);
277                        }
278                case V3_0:
279                case V4_0:
280                        try {
281                                return new Timezone(parse(value));
282                        } catch (IllegalArgumentException e) {
283                                if (dataType == VCardDataType.UTC_OFFSET) {
284                                        context.addWarning(20);
285                                }
286                                return new Timezone(value);
287                        }
288                }
289
290                return new Timezone((String) null);
291        }
292
293        /**
294         * <p>
295         * Parses a UTC offset from a string.
296         * </p>
297         * <p>
298         * {@link ZoneOffset#of(String)} cannot be used because we need to be able
299         * to parse inputs that lack a sign and two-digit hour (e.g. "1:00").
300         * </p>
301         * @param text the text to parse (e.g. "-0500")
302         * @return the parsed UTC offset
303         * @throws IllegalArgumentException if the text cannot be parsed
304         */
305        private ZoneOffset parse(String text) {
306                int i = 0;
307                char sign = text.charAt(i);
308                boolean negative = false;
309                if (sign == '-') {
310                        negative = true;
311                        i++;
312                } else if (sign == '+') {
313                        i++;
314                }
315
316                int maxLength = i + 4;
317                int colon = text.indexOf(':', i);
318                if (colon >= 0) {
319                        maxLength++;
320                }
321                if (text.length() > maxLength) {
322                        throw Messages.INSTANCE.getIllegalArgumentException(40, text);
323                }
324
325                String hourStr, minuteStr = null;
326                if (colon < 0) {
327                        hourStr = text.substring(i);
328                        int minutePos = hourStr.length() - 2;
329                        if (minutePos > 0) {
330                                minuteStr = hourStr.substring(minutePos);
331                                hourStr = hourStr.substring(0, minutePos);
332                        }
333                } else {
334                        hourStr = text.substring(i, colon);
335                        if (colon < text.length() - 1) {
336                                minuteStr = text.substring(colon + 1);
337                        }
338                }
339
340                int hour, minute;
341                try {
342                        hour = Integer.parseInt(hourStr);
343                        minute = (minuteStr == null) ? 0 : Integer.parseInt(minuteStr);
344                } catch (NumberFormatException e) {
345                        throw Messages.INSTANCE.getIllegalArgumentException(40, text);
346                }
347
348                if (negative) {
349                        hour *= -1;
350                        minute *= -1;
351                }
352
353                return ZoneOffset.ofHoursMinutes(hour, minute);
354        }
355}