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}