001package org.hl7.fhir.dstu2.model;
002
003/*
004  Copyright (c) 2011+, HL7, Inc.
005  All rights reserved.
006  
007  Redistribution and use in source and binary forms, with or without modification, 
008  are permitted provided that the following conditions are met:
009    
010   * Redistributions of source code must retain the above copyright notice, this 
011     list of conditions and the following disclaimer.
012   * Redistributions in binary form must reproduce the above copyright notice, 
013     this list of conditions and the following disclaimer in the documentation 
014     and/or other materials provided with the distribution.
015   * Neither the name of HL7 nor the names of its contributors may be used to 
016     endorse or promote products derived from this software without specific 
017     prior written permission.
018  
019  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 
020  ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 
021  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 
022  IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 
023  INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 
024  NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 
025  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 
026  WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 
027  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 
028  POSSIBILITY OF SUCH DAMAGE.
029  
030 */
031
032import ca.uhn.fhir.model.api.TemporalPrecisionEnum;
033import org.apache.commons.lang3.StringUtils;
034import org.apache.commons.lang3.Validate;
035import org.apache.commons.lang3.time.DateUtils;
036import org.apache.commons.lang3.time.FastDateFormat;
037import org.hl7.fhir.utilities.DateTimeUtil;
038
039import javax.annotation.Nullable;
040import java.text.ParseException;
041import java.util.*;
042import java.util.concurrent.ConcurrentHashMap;
043import java.util.regex.Pattern;
044
045import static ca.uhn.fhir.model.api.TemporalPrecisionEnum.*;
046
047public abstract class BaseDateTimeType extends PrimitiveType<Date> {
048
049  private static final long serialVersionUID = 1L;
050
051  /*
052   * Add any new formatters to the static block below!!
053   */
054  private static final List<FastDateFormat> ourFormatters;
055
056  private static final Pattern ourYearDashMonthDashDayPattern = Pattern.compile("[0-9]{4}-[0-9]{2}-[0-9]{2}");
057  private static final Pattern ourYearDashMonthPattern = Pattern.compile("[0-9]{4}-[0-9]{2}");
058  private static final FastDateFormat ourYearFormat = FastDateFormat.getInstance("yyyy");
059  private static final FastDateFormat ourYearMonthDayFormat = FastDateFormat.getInstance("yyyy-MM-dd");
060  private static final FastDateFormat ourYearMonthDayNoDashesFormat = FastDateFormat.getInstance("yyyyMMdd");
061  private static final Pattern ourYearMonthDayPattern = Pattern.compile("[0-9]{4}[0-9]{2}[0-9]{2}");
062  private static final FastDateFormat ourYearMonthDayTimeFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss");
063  private static final FastDateFormat ourYearMonthDayTimeMilliFormat = FastDateFormat
064      .getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS");
065  private static final FastDateFormat ourYearMonthDayTimeMilliUTCZFormat = FastDateFormat
066      .getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone.getTimeZone("UTC"));
067  private static final FastDateFormat ourYearMonthDayTimeMilliZoneFormat = FastDateFormat
068      .getInstance("yyyy-MM-dd'T'HH:mm:ss.SSSZZ");
069  private static final FastDateFormat ourYearMonthDayTimeUTCZFormat = FastDateFormat
070      .getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("UTC"));
071  private static final FastDateFormat ourYearMonthDayTimeZoneFormat = FastDateFormat
072      .getInstance("yyyy-MM-dd'T'HH:mm:ssZZ");
073  private static final FastDateFormat ourYearMonthFormat = FastDateFormat.getInstance("yyyy-MM");
074  private static final FastDateFormat ourYearMonthNoDashesFormat = FastDateFormat.getInstance("yyyyMM");
075  private static final Pattern ourYearMonthPattern = Pattern.compile("[0-9]{4}[0-9]{2}");
076  private static final Pattern ourYearPattern = Pattern.compile("[0-9]{4}");
077  private static final FastDateFormat ourYearMonthDayTimeMinsFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm");
078  private static final FastDateFormat ourYearMonthDayTimeMinsUTCZFormat = FastDateFormat
079      .getInstance("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC"));
080  private static final FastDateFormat ourYearMonthDayTimeMinsZoneFormat = FastDateFormat
081      .getInstance("yyyy-MM-dd'T'HH:mmZZ");
082
083  private static final FastDateFormat ourHumanDateTimeFormat = FastDateFormat.getDateTimeInstance(FastDateFormat.MEDIUM,
084      FastDateFormat.MEDIUM);
085  private static final FastDateFormat ourHumanDateFormat = FastDateFormat.getDateInstance(FastDateFormat.MEDIUM);
086  private static final Map<String, TimeZone> timezoneCache = new ConcurrentHashMap<>();
087
088  static {
089    ArrayList<FastDateFormat> formatters = new ArrayList<FastDateFormat>();
090    formatters.add(ourYearFormat);
091    formatters.add(ourYearMonthDayFormat);
092    formatters.add(ourYearMonthDayNoDashesFormat);
093    formatters.add(ourYearMonthDayTimeFormat);
094    formatters.add(ourYearMonthDayTimeUTCZFormat);
095    formatters.add(ourYearMonthDayTimeZoneFormat);
096    formatters.add(ourYearMonthDayTimeMilliFormat);
097    formatters.add(ourYearMonthDayTimeMilliUTCZFormat);
098    formatters.add(ourYearMonthDayTimeMilliZoneFormat);
099    formatters.add(ourYearMonthDayTimeMinsFormat);
100    formatters.add(ourYearMonthDayTimeMinsUTCZFormat);
101    formatters.add(ourYearMonthDayTimeMinsZoneFormat);
102    formatters.add(ourYearMonthFormat);
103    formatters.add(ourYearMonthNoDashesFormat);
104    ourFormatters = Collections.unmodifiableList(formatters);
105  }
106
107  private TemporalPrecisionEnum myPrecision = TemporalPrecisionEnum.SECOND;
108
109  private TimeZone myTimeZone;
110  private boolean myTimeZoneZulu = false;
111
112  /**
113   * Constructor
114   */
115  public BaseDateTimeType() {
116    // nothing
117  }
118
119  /**
120   * Constructor
121   *
122   * @throws IllegalArgumentException If the specified precision is not allowed
123   *                                  for this type
124   */
125  public BaseDateTimeType(Date theDate, TemporalPrecisionEnum thePrecision) {
126    setValue(theDate, thePrecision);
127    validatePrecisionAndThrowIllegalArgumentException();
128  }
129
130  /**
131   * Constructor
132   *
133   * @throws IllegalArgumentException If the specified precision is not allowed
134   *                                  for this type
135   */
136  public BaseDateTimeType(String theString) {
137    setValueAsString(theString);
138    validatePrecisionAndThrowIllegalArgumentException();
139  }
140
141  /**
142   * Constructor
143   */
144  public BaseDateTimeType(Date theDate, TemporalPrecisionEnum thePrecision, TimeZone theTimeZone) {
145    this(theDate, thePrecision);
146    setTimeZone(theTimeZone);
147    validatePrecisionAndThrowIllegalArgumentException();
148  }
149
150  private void clearTimeZone() {
151    myTimeZone = null;
152    myTimeZoneZulu = false;
153  }
154
155  private void validatePrecisionAndThrowIllegalArgumentException() {
156    if (!isPrecisionAllowed(getPrecision())) {
157      throw new IllegalArgumentException("Invalid date/time string (datatype " + getClass().getSimpleName()
158          + " does not support " + getPrecision() + " precision): " + getValueAsString());
159    }
160  }
161
162  /**
163   * @param thePrecision
164   * @return the String value of this instance with the specified precision.
165   */
166  public String getValueAsString(TemporalPrecisionEnum thePrecision) {
167    return encode(getValue(), thePrecision);
168  }
169
170  @Override
171  protected String encode(Date theValue) {
172    return encode(theValue, myPrecision);
173  }
174
175  @Nullable
176  private String encode(Date theValue, TemporalPrecisionEnum thePrecision) {
177    if (theValue == null) {
178      return null;
179    } else {
180      switch (thePrecision) {
181      case DAY:
182        return ourYearMonthDayFormat.format(theValue);
183      case MONTH:
184        return ourYearMonthFormat.format(theValue);
185      case YEAR:
186        return ourYearFormat.format(theValue);
187      case MINUTE:
188        if (myTimeZoneZulu) {
189          GregorianCalendar cal = new GregorianCalendar(getTimeZone("GMT"));
190          cal.setTime(theValue);
191          return ourYearMonthDayTimeMinsFormat.format(cal) + "Z";
192        } else if (myTimeZone != null) {
193          GregorianCalendar cal = new GregorianCalendar(myTimeZone);
194          cal.setTime(theValue);
195          return (ourYearMonthDayTimeMinsZoneFormat.format(cal));
196        } else {
197          return ourYearMonthDayTimeMinsFormat.format(theValue);
198        }
199      case SECOND:
200        if (myTimeZoneZulu) {
201          GregorianCalendar cal = new GregorianCalendar(getTimeZone("GMT"));
202          cal.setTime(theValue);
203          return ourYearMonthDayTimeFormat.format(cal) + "Z";
204        } else if (myTimeZone != null) {
205          GregorianCalendar cal = new GregorianCalendar(myTimeZone);
206          cal.setTime(theValue);
207          return (ourYearMonthDayTimeZoneFormat.format(cal));
208        } else {
209          return ourYearMonthDayTimeFormat.format(theValue);
210        }
211      case MILLI:
212        if (myTimeZoneZulu) {
213          GregorianCalendar cal = new GregorianCalendar(getTimeZone("GMT"));
214          cal.setTime(theValue);
215          return ourYearMonthDayTimeMilliFormat.format(cal) + "Z";
216        } else if (myTimeZone != null) {
217          GregorianCalendar cal = new GregorianCalendar(myTimeZone);
218          cal.setTime(theValue);
219          return (ourYearMonthDayTimeMilliZoneFormat.format(cal));
220        } else {
221          return ourYearMonthDayTimeMilliFormat.format(theValue);
222        }
223      }
224      throw new IllegalStateException(
225          "Invalid precision (this is a bug, shouldn't happen https://xkcd.com/2200/): " + thePrecision);
226    }
227  }
228
229  /**
230   * Returns the default precision for the given datatype
231   */
232  protected abstract TemporalPrecisionEnum getDefaultPrecisionForDatatype();
233
234  /**
235   * Gets the precision for this datatype (using the default for the given type if
236   * not set)
237   *
238   * @see #setPrecision(TemporalPrecisionEnum)
239   */
240  public TemporalPrecisionEnum getPrecision() {
241    if (myPrecision == null) {
242      return getDefaultPrecisionForDatatype();
243    }
244    return myPrecision;
245  }
246
247  /**
248   * Returns the TimeZone associated with this dateTime's value. May return
249   * <code>null</code> if no timezone was supplied.
250   */
251  public TimeZone getTimeZone() {
252    return myTimeZone;
253  }
254
255  private boolean hasOffset(String theValue) {
256    boolean inTime = false;
257    for (int i = 0; i < theValue.length(); i++) {
258      switch (theValue.charAt(i)) {
259      case 'T':
260        inTime = true;
261        break;
262      case '+':
263      case '-':
264        if (inTime) {
265          return true;
266        }
267        break;
268      }
269    }
270    return false;
271  }
272
273  /**
274   * To be implemented by subclasses to indicate whether the given precision is
275   * allowed by this type
276   */
277  abstract boolean isPrecisionAllowed(TemporalPrecisionEnum thePrecision);
278
279  public boolean isTimeZoneZulu() {
280    return myTimeZoneZulu;
281  }
282
283  /**
284   * Returns <code>true</code> if this object represents a date that is today's
285   * date
286   *
287   * @throws NullPointerException if {@link #getValue()} returns <code>null</code>
288   */
289  public boolean isToday() {
290    Validate.notNull(getValue(), getClass().getSimpleName() + " contains null value");
291    return DateUtils.isSameDay(new Date(), getValue());
292  }
293
294  @Override
295  protected Date parse(String theValue) throws IllegalArgumentException {
296    try {
297      if (theValue.length() == 4 && ourYearPattern.matcher(theValue).matches()) {
298        setPrecision(YEAR);
299        clearTimeZone();
300        return ((ourYearFormat).parse(theValue));
301      } else if (theValue.length() == 6 && ourYearMonthPattern.matcher(theValue).matches()) {
302        // Eg. 198401 (allow this just to be lenient)
303        setPrecision(MONTH);
304        clearTimeZone();
305        return ((ourYearMonthNoDashesFormat).parse(theValue));
306      } else if (theValue.length() == 7 && ourYearDashMonthPattern.matcher(theValue).matches()) {
307        // E.g. 1984-01 (this is valid according to the spec)
308        setPrecision(MONTH);
309        clearTimeZone();
310        return ((ourYearMonthFormat).parse(theValue));
311      } else if (theValue.length() == 8 && ourYearMonthDayPattern.matcher(theValue).matches()) {
312        // Eg. 19840101 (allow this just to be lenient)
313        setPrecision(DAY);
314        clearTimeZone();
315        return ((ourYearMonthDayNoDashesFormat).parse(theValue));
316      } else if (theValue.length() == 10 && ourYearDashMonthDashDayPattern.matcher(theValue).matches()) {
317        // E.g. 1984-01-01 (this is valid according to the spec)
318        setPrecision(DAY);
319        clearTimeZone();
320        return ((ourYearMonthDayFormat).parse(theValue));
321      } else if (theValue.length() >= 16) { // date and time with possible time zone
322        int firstColonIndex = theValue.indexOf(':');
323        if (firstColonIndex == -1) {
324          throw new IllegalArgumentException("Invalid date/time string: " + theValue);
325        }
326
327        boolean hasSeconds = theValue.length() > firstColonIndex + 3 ? theValue.charAt(firstColonIndex + 3) == ':'
328            : false;
329
330        int dotIndex = theValue.length() >= 18 ? theValue.indexOf('.', 18) : -1;
331        boolean hasMillis = dotIndex > -1;
332
333        Date retVal;
334        if (hasMillis) {
335          try {
336            if (hasOffset(theValue)) {
337              retVal = ourYearMonthDayTimeMilliZoneFormat.parse(theValue);
338            } else if (theValue.endsWith("Z")) {
339              retVal = ourYearMonthDayTimeMilliUTCZFormat.parse(theValue);
340            } else {
341              retVal = ourYearMonthDayTimeMilliFormat.parse(theValue);
342            }
343          } catch (ParseException p2) {
344            throw new IllegalArgumentException("Invalid data/time string (" + p2.getMessage() + "): " + theValue);
345          }
346          setTimeZone(theValue, hasMillis);
347          setPrecision(TemporalPrecisionEnum.MILLI);
348        } else if (hasSeconds) {
349          try {
350            if (hasOffset(theValue)) {
351              retVal = ourYearMonthDayTimeZoneFormat.parse(theValue);
352            } else if (theValue.endsWith("Z")) {
353              retVal = ourYearMonthDayTimeUTCZFormat.parse(theValue);
354            } else {
355              retVal = ourYearMonthDayTimeFormat.parse(theValue);
356            }
357          } catch (ParseException p2) {
358            throw new IllegalArgumentException("Invalid data/time string (" + p2.getMessage() + "): " + theValue);
359          }
360
361          setTimeZone(theValue, hasMillis);
362          setPrecision(TemporalPrecisionEnum.SECOND);
363        } else {
364          try {
365            if (hasOffset(theValue)) {
366              retVal = ourYearMonthDayTimeMinsZoneFormat.parse(theValue);
367            } else if (theValue.endsWith("Z")) {
368              retVal = ourYearMonthDayTimeMinsUTCZFormat.parse(theValue);
369            } else {
370              retVal = ourYearMonthDayTimeMinsFormat.parse(theValue);
371            }
372          } catch (ParseException p2) {
373            throw new IllegalArgumentException("Invalid data/time string (" + p2.getMessage() + "): " + theValue, p2);
374          }
375
376          setTimeZone(theValue, hasMillis);
377          setPrecision(TemporalPrecisionEnum.MINUTE);
378        }
379
380        return retVal;
381      } else {
382        throw new IllegalArgumentException("Invalid date/time string (invalid length): " + theValue);
383      }
384    } catch (ParseException e) {
385      throw new IllegalArgumentException("Invalid date string (" + e.getMessage() + "): " + theValue);
386    }
387  }
388
389  /**
390   * Sets the precision for this datatype using field values from
391   * {@link Calendar}. Valid values are:
392   * <ul>
393   * <li>{@link Calendar#SECOND}
394   * <li>{@link Calendar#DAY_OF_MONTH}
395   * <li>{@link Calendar#MONTH}
396   * <li>{@link Calendar#YEAR}
397   * </ul>
398   *
399   * @throws IllegalArgumentException
400   */
401  public void setPrecision(TemporalPrecisionEnum thePrecision) throws IllegalArgumentException {
402    if (thePrecision == null) {
403      throw new NullPointerException("Precision may not be null");
404    }
405    myPrecision = thePrecision;
406    updateStringValue();
407  }
408
409  private void setTimeZone(String theValueString, boolean hasMillis) {
410    clearTimeZone();
411    int timeZoneStart = 19;
412    if (hasMillis)
413      timeZoneStart += 4;
414    if (theValueString.endsWith("Z")) {
415      setTimeZoneZulu(true);
416    } else if (theValueString.indexOf("GMT", timeZoneStart) != -1) {
417      setTimeZone(getTimeZone(theValueString.substring(timeZoneStart)));
418    } else if (theValueString.indexOf('+', timeZoneStart) != -1 || theValueString.indexOf('-', timeZoneStart) != -1) {
419      setTimeZone(getTimeZone("GMT" + theValueString.substring(timeZoneStart)));
420    }
421  }
422
423  public void setTimeZone(TimeZone theTimeZone) {
424    myTimeZone = theTimeZone;
425    updateStringValue();
426  }
427
428  public void setTimeZoneZulu(boolean theTimeZoneZulu) {
429    myTimeZoneZulu = theTimeZoneZulu;
430    updateStringValue();
431  }
432
433  /**
434   * Sets the value of this date/time using the default level of precision for
435   * this datatype using the system local time zone
436   *
437   * @param theValue The date value
438   */
439  @Override
440  public BaseDateTimeType setValue(Date theValue) {
441    if (myTimeZoneZulu == false && myTimeZone == null) {
442      myTimeZone = TimeZone.getDefault();
443    }
444    myPrecision = getDefaultPrecisionForDatatype();
445    BaseDateTimeType retVal = (BaseDateTimeType) super.setValue(theValue);
446    return retVal;
447  }
448
449  /**
450   * Sets the value of this date/time using the specified level of precision using
451   * the system local time zone
452   *
453   * @param theValue     The date value
454   * @param thePrecision The precision
455   * @throws IllegalArgumentException
456   */
457  public void setValue(Date theValue, TemporalPrecisionEnum thePrecision) throws IllegalArgumentException {
458    if (myTimeZoneZulu == false && myTimeZone == null) {
459      myTimeZone = TimeZone.getDefault();
460    }
461    myPrecision = thePrecision;
462    super.setValue(theValue);
463  }
464
465  @Override
466  public void setValueAsString(String theString) throws IllegalArgumentException {
467    clearTimeZone();
468    super.setValueAsString(theString);
469  }
470
471  /**
472   * For unit tests only
473   */
474  static List<FastDateFormat> getFormatters() {
475    return ourFormatters;
476  }
477
478  public boolean before(DateTimeType theDateTimeType) {
479    return getValue().before(theDateTimeType.getValue());
480  }
481
482  public boolean after(DateTimeType theDateTimeType) {
483    return getValue().after(theDateTimeType.getValue());
484  }
485
486  /**
487   * Returns a human readable version of this date/time using the system local
488   * format.
489   * <p>
490   * <b>Note on time zones:</b> This method renders the value using the time zone
491   * that is contained within the value. For example, if this date object contains
492   * the value "2012-01-05T12:00:00-08:00", the human display will be rendered as
493   * "12:00:00" even if the application is being executed on a system in a
494   * different time zone. If this behaviour is not what you want, use
495   * {@link #toHumanDisplayLocalTimezone()} instead.
496   * </p>
497   */
498  public String toHumanDisplay() {
499    return DateTimeUtil.toHumanDisplay(getTimeZone(), getPrecision(), getValue(), getValueAsString());
500  }
501
502  /**
503   * Returns a human readable version of this date/time using the system local
504   * format, converted to the local timezone if neccesary.
505   *
506   * @see #toHumanDisplay() for a method which does not convert the time to the
507   *      local timezone before rendering it.
508   */
509  public String toHumanDisplayLocalTimezone() {
510    return DateTimeUtil.toHumanDisplayLocalTimezone(getPrecision(), getValue(), getValueAsString());
511  }
512
513  /**
514   * Returns a view of this date/time as a Calendar object
515   */
516  public Calendar toCalendar() {
517    Calendar retVal = Calendar.getInstance();
518    retVal.setTime(getValue());
519    retVal.setTimeZone(getTimeZone());
520    return retVal;
521  }
522
523  /**
524   * Sets the TimeZone offset in minutes relative to GMT
525   */
526  public void setOffsetMinutes(int theZoneOffsetMinutes) {
527    int offsetAbs = Math.abs(theZoneOffsetMinutes);
528
529    int mins = offsetAbs % 60;
530    int hours = offsetAbs / 60;
531
532    if (theZoneOffsetMinutes < 0) {
533      setTimeZone(getTimeZone("GMT-" + hours + ":" + mins));
534    } else {
535      setTimeZone(getTimeZone("GMT+" + hours + ":" + mins));
536    }
537  }
538
539  /**
540   * Returns the time in millis as represented by this Date/Time
541   */
542  public long getTime() {
543    return getValue().getTime();
544  }
545
546  /**
547   * Adds the given amount to the field specified by theField
548   *
549   * @param theField The field, uses constants from {@link Calendar} such as
550   *                 {@link Calendar#YEAR}
551   * @param theValue The number to add (or subtract for a negative number)
552   */
553  public void add(int theField, int theValue) {
554    switch (theField) {
555    case Calendar.YEAR:
556      setValue(DateUtils.addYears(getValue(), theValue), getPrecision());
557      break;
558    case Calendar.MONTH:
559      setValue(DateUtils.addMonths(getValue(), theValue), getPrecision());
560      break;
561    case Calendar.DATE:
562      setValue(DateUtils.addDays(getValue(), theValue), getPrecision());
563      break;
564    case Calendar.HOUR:
565      setValue(DateUtils.addHours(getValue(), theValue), getPrecision());
566      break;
567    case Calendar.MINUTE:
568      setValue(DateUtils.addMinutes(getValue(), theValue), getPrecision());
569      break;
570    case Calendar.SECOND:
571      setValue(DateUtils.addSeconds(getValue(), theValue), getPrecision());
572      break;
573    case Calendar.MILLISECOND:
574      setValue(DateUtils.addMilliseconds(getValue(), theValue), getPrecision());
575      break;
576    default:
577      throw new IllegalArgumentException("Unknown field constant: " + theField);
578    }
579  }
580
581  protected void setValueAsV3String(String theV3String) {
582    if (StringUtils.isBlank(theV3String)) {
583      setValue(null);
584    } else {
585      StringBuilder b = new StringBuilder();
586      String timeZone = null;
587      for (int i = 0; i < theV3String.length(); i++) {
588        char nextChar = theV3String.charAt(i);
589        if (nextChar == '+' || nextChar == '-' || nextChar == 'Z') {
590          timeZone = (theV3String.substring(i));
591          break;
592        }
593
594        // assertEquals("2013-02-02T20:13:03-05:00",
595        // DateAndTime.parseV3("20130202201303-0500").toString());
596        if (i == 4 || i == 6) {
597          b.append('-');
598        } else if (i == 8) {
599          b.append('T');
600        } else if (i == 10 || i == 12) {
601          b.append(':');
602        }
603
604        b.append(nextChar);
605      }
606
607      if (b.length() == 16)
608        b.append(":00"); // schema rule, must have seconds
609      if (timeZone != null && b.length() > 10) {
610        if (timeZone.length() == 5) {
611          b.append(timeZone.substring(0, 3));
612          b.append(':');
613          b.append(timeZone.substring(3));
614        } else {
615          b.append(timeZone);
616        }
617      }
618
619      setValueAsString(b.toString());
620    }
621  }
622
623  private TimeZone getTimeZone(String offset) {
624    return timezoneCache.computeIfAbsent(offset, TimeZone::getTimeZone);
625  }
626
627}