001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.commons.time; 018 019import static org.apache.juneau.commons.lang.StateEnum.*; 020import static org.apache.juneau.commons.utils.AssertionUtils.*; 021 022import java.time.*; 023import java.time.format.*; 024import java.time.temporal.*; 025import java.util.*; 026 027import org.apache.juneau.commons.utils.*; 028 029/** 030 * A ZonedDateTime with precision information for granular time operations. 031 * 032 * <p> 033 * This class combines a {@link ZonedDateTime} with a {@link ChronoField} precision identifier, 034 * allowing for granular time operations such as rolling by specific time units. 035 * 036 * <p> 037 * The precision field indicates the granularity of the time value, which determines how the 038 * {@link #roll(int)} method behaves. For example, a precision of {@link ChronoField#YEAR} means 039 * rolling by 1 will advance the year, while a precision of {@link ChronoField#HOUR_OF_DAY} means 040 * rolling by 1 will advance the hour. 041 * 042 * <h5 class='section'>ISO8601 Parsing:</h5> 043 * <p> 044 * The {@link #of(String)} method can parse various ISO8601 timestamp formats: 045 * <ul> 046 * <li>Date formats: <js>"2011"</js>, <js>"2011-01"</js>, <js>"2011-01-15"</js> 047 * <li>DateTime formats: <js>"2011-01-15T12"</js>, <js>"2011-01-15T12:30"</js>, <js>"2011-01-15T12:30:45"</js> 048 * <li>With fractional seconds: <js>"2011-01-15T12:30:45.123"</js>, <js>"2011-01-15T12:30:45,123"</js> 049 * <li>Time-only formats: <js>"T12"</js>, <js>"T12:30"</js>, <js>"T12:30:45"</js> 050 * <li>With timezone: <js>"2011-01-15T12:30:45Z"</js>, <js>"2011-01-15T12:30:45+05:30"</js>, <js>"2011-01-15T12:30:45-05:30"</js> 051 * </ul> 052 * 053 * <p> 054 * The precision is automatically determined from the input format. For example: 055 * <ul> 056 * <li><js>"2011"</js> → {@link ChronoField#YEAR} 057 * <li><js>"2011-01"</js> → {@link ChronoField#MONTH_OF_YEAR} 058 * <li><js>"2011-01-15"</js> → {@link ChronoField#DAY_OF_MONTH} 059 * <li><js>"2011-01-15T12"</js> → {@link ChronoField#HOUR_OF_DAY} 060 * <li><js>"2011-01-15T12:30"</js> → {@link ChronoField#MINUTE_OF_HOUR} 061 * <li><js>"2011-01-15T12:30:45"</js> → {@link ChronoField#SECOND_OF_MINUTE} 062 * <li><js>"2011-01-15T12:30:45.123"</js> → {@link ChronoField#MILLI_OF_SECOND} (1-3 digits) 063 * <li><js>"2011-01-15T12:30:45.123456789"</js> → {@link ChronoField#NANO_OF_SECOND} (4-9 digits) 064 * </ul> 065 * 066 * <h5 class='section'>Example:</h5> 067 * <p class='bjava'> 068 * <jc>// Parse an ISO8601 timestamp with year precision</jc> 069 * GranularZonedDateTime <jv>gdt</jv> = GranularZonedDateTime.<jsm>of</jsm>(<js>"2011"</js>); 070 * <jc>// Roll forward by one year</jc> 071 * <jv>gdt</jv> = <jv>gdt</jv>.<jsm>roll</jsm>(1); 072 * <jc>// Get the ZonedDateTime</jc> 073 * ZonedDateTime <jv>zdt</jv> = <jv>gdt</jv>.<jsm>getZonedDateTime</jsm>(); 074 * <jc>// Result: 2012-01-01T00:00:00 with system default timezone</jc> 075 * </p> 076 * 077 * <p class='bjava'> 078 * <jc>// Parse a datetime with hour precision</jc> 079 * GranularZonedDateTime <jv>gdt2</jv> = GranularZonedDateTime.<jsm>of</jsm>(<js>"2011-01-15T12Z"</js>); 080 * <jc>// Roll forward by 2 hours</jc> 081 * <jv>gdt2</jv> = <jv>gdt2</jv>.<jsm>roll</jsm>(<jv>ChronoField</jv>.<jf>HOUR_OF_DAY</jf>, 2); 082 * <jc>// Result: 2011-01-15T14:00:00Z</jc> 083 * </p> 084 * 085 * <h5 class='section'>Thread Safety:</h5> 086 * <p> 087 * This class is immutable and thread-safe. 088 * 089 * <h5 class='section'>See Also:</h5><ul> 090 * <li class='jm'>{@link ZonedDateTime} 091 * <li class='jm'>{@link ChronoField} 092 * <li class='jm'>{@link DateUtils} 093 * </ul> 094 */ 095public class GranularZonedDateTime { 096 097 /** 098 * Creates a GranularZonedDateTime from a Date with the specified precision. 099 * 100 * <p> 101 * The date is converted to a ZonedDateTime using the system default timezone. 102 * 103 * @param date The date to convert. 104 * @param precision The precision of the time value. 105 * @return A new GranularZonedDateTime instance. 106 * @throws IllegalArgumentException if date or precision is null. 107 */ 108 public static GranularZonedDateTime of(Date date, ChronoField precision) { 109 return of(date, precision, ZoneId.systemDefault()); 110 } 111 112 /** 113 * Creates a GranularZonedDateTime from a Date with the specified precision and timezone. 114 * 115 * <p> 116 * The date is converted to a ZonedDateTime using the specified timezone. 117 * 118 * @param date The date to convert. 119 * @param precision The precision of the time value. 120 * @param zoneId The timezone to use. 121 * @return A new GranularZonedDateTime instance. 122 * @throws IllegalArgumentException if date, precision, or zoneId is null. 123 */ 124 public static GranularZonedDateTime of(Date date, ChronoField precision, ZoneId zoneId) { 125 return of(date.toInstant().atZone(zoneId), precision); 126 } 127 128 /** 129 * Parses an ISO8601 timestamp string into a GranularZonedDateTime. 130 * 131 * <p> 132 * This method parses various ISO8601 formats and automatically determines the precision 133 * based on the format. If no timezone is specified in the string, the system default 134 * timezone is used. 135 * 136 * <h5 class='section'>Supported Formats:</h5> 137 * <ul> 138 * <li>Date: <js>"2011"</js>, <js>"2011-01"</js>, <js>"2011-01-15"</js> 139 * <li>DateTime: <js>"2011-01-15T12"</js>, <js>"2011-01-15T12:30"</js>, <js>"2011-01-15T12:30:45"</js> 140 * <li>With fractional seconds: <js>"2011-01-15T12:30:45.123"</js>, <js>"2011-01-15T12:30:45,123"</js> 141 * <li>Time-only: <js>"T12"</js>, <js>"T12:30"</js>, <js>"T12:30:45"</js> (uses current date) 142 * <li>With timezone: <js>"2011-01-15T12:30:45Z"</js>, <js>"2011-01-15T12:30:45+05:30"</js>, <js>"2011-01-15T12:30:45-05:30"</js> 143 * </ul> 144 * 145 * <h5 class='section'>Timezone Handling:</h5> 146 * <ul> 147 * <li>If the string contains a timezone (Z, +HH:mm, -HH:mm, etc.), that timezone is used. 148 * <li>If no timezone is specified, the system default timezone is used. 149 * <li>For time-only formats (starting with "T"), the current date is used with the specified or default timezone. 150 * </ul> 151 * 152 * <h5 class='section'>Precision Detection:</h5> 153 * <p> 154 * The precision is automatically determined from the format: 155 * <ul> 156 * <li>Year only → {@link ChronoField#YEAR} 157 * <li>Year-Month → {@link ChronoField#MONTH_OF_YEAR} 158 * <li>Year-Month-Day → {@link ChronoField#DAY_OF_MONTH} 159 * <li>With hour → {@link ChronoField#HOUR_OF_DAY} 160 * <li>With minute → {@link ChronoField#MINUTE_OF_HOUR} 161 * <li>With second → {@link ChronoField#SECOND_OF_MINUTE} 162 * <li>With 1-3 fractional digits → {@link ChronoField#MILLI_OF_SECOND} 163 * <li>With 4-9 fractional digits → {@link ChronoField#NANO_OF_SECOND} 164 * </ul> 165 * 166 * @param value The ISO8601 timestamp string to parse. 167 * @return A new GranularZonedDateTime instance. 168 * @throws IllegalArgumentException if timestamp is null. 169 * @throws DateTimeParseException if the timestamp format is invalid. 170 */ 171 public static GranularZonedDateTime of(String value) { 172 return of(value, null, null); 173 } 174 175 /** 176 * Parses an ISO8601 timestamp string into a GranularZonedDateTime with a custom time provider. 177 * 178 * <p> 179 * This method is similar to {@link #of(String)}, but allows you to specify a custom 180 * {@link TimeProvider} to use for obtaining the system default timezone and current time. 181 * This is useful for testing or when you need deterministic time behavior. 182 * 183 * <p> 184 * The time provider is used when: 185 * <ul> 186 * <li>No timezone is specified in the string - uses {@link TimeProvider#getSystemDefaultZoneId()} 187 * <li>Time-only formats (starting with "T") - uses {@link TimeProvider#now(ZoneId)} to get the current date 188 * </ul> 189 * 190 * <h5 class='section'>Example:</h5> 191 * <p class='bjava'> 192 * <jc>// Parse with custom time provider for testing</jc> 193 * <jk>var</jk> <jv>timeProvider</jv> = <jk>new</jk> FakeTimeProvider(); 194 * GranularZonedDateTime <jv>gdt</jv> = GranularZonedDateTime.<jsm>of</jsm>( 195 * <js>"T12:30:45"</js>, 196 * <jv>timeProvider</jv> 197 * ); 198 * <jc>// Result uses the time provider's current date and timezone</jc> 199 * </p> 200 * 201 * @param value The ISO8601 timestamp string to parse. 202 * @param timeProvider The time provider to use for system default timezone and current time. 203 * If null, {@link TimeProvider#INSTANCE} is used. 204 * @return A new GranularZonedDateTime instance. 205 * @throws IllegalArgumentException if value is null. 206 * @throws DateTimeParseException if the timestamp format is invalid. 207 */ 208 public static GranularZonedDateTime of(String value, TimeProvider timeProvider) { 209 return of(value, null, timeProvider); 210 } 211 212 213 /** 214 * Parses an ISO8601 timestamp string into a GranularZonedDateTime with a default timezone and custom time provider. 215 * 216 * <p> 217 * This method is similar to {@link #of(String)}, but allows you to specify both a default 218 * timezone and a custom {@link TimeProvider} to use when no timezone is present in the timestamp string. 219 * 220 * <p> 221 * If the timestamp string contains a timezone (Z, +HH:mm, -HH:mm, etc.), that timezone 222 * takes precedence over the defaultZoneId parameter. The defaultZoneId is only used when 223 * no timezone is specified in the string. 224 * 225 * <p> 226 * The time provider is used when: 227 * <ul> 228 * <li>No timezone is specified and defaultZoneId is null - uses {@link TimeProvider#getSystemDefaultZoneId()} 229 * <li>Time-only formats (starting with "T") - uses {@link TimeProvider#now(ZoneId)} to get the current date 230 * </ul> 231 * 232 * <h5 class='section'>Example:</h5> 233 * <p class='bjava'> 234 * <jc>// Parse with default timezone and custom time provider</jc> 235 * <jk>var</jk> <jv>timeProvider</jv> = <jk>new</jk> FakeTimeProvider(); 236 * GranularZonedDateTime <jv>gdt1</jv> = GranularZonedDateTime.<jsm>of</jsm>( 237 * <js>"2011-01-15T12:30:45"</js>, 238 * <jv>ZoneId</jv>.<jsm>of</jsm>(<js>"America/New_York"</js>), 239 * <jv>timeProvider</jv> 240 * ); 241 * <jc>// Result uses America/New_York timezone</jc> 242 * 243 * <jc>// Parse with timezone in string (defaultZoneId is ignored)</jc> 244 * GranularZonedDateTime <jv>gdt2</jv> = GranularZonedDateTime.<jsm>of</jsm>( 245 * <js>"2011-01-15T12:30:45Z"</js>, 246 * <jv>ZoneId</jv>.<jsm>of</jsm>(<js>"America/New_York"</js>), 247 * <jv>timeProvider</jv> 248 * ); 249 * <jc>// Result uses UTC (Z), not America/New_York</jc> 250 * </p> 251 * 252 * @param value The ISO8601 timestamp string to parse. 253 * @param defaultZoneId The default timezone to use if no timezone is specified in the string. 254 * If null, the time provider's system default timezone is used. 255 * @param timeProvider The time provider to use for system default timezone and current time. 256 * If null, {@link TimeProvider#INSTANCE} is used. 257 * @return A new GranularZonedDateTime instance. 258 * @throws IllegalArgumentException if value is null. 259 * @throws DateTimeParseException if the timestamp format is invalid. 260 */ 261 public static GranularZonedDateTime of(String value, ZoneId defaultZoneId, TimeProvider timeProvider) { 262 assertArgNotNull("value", value); 263 var digit = StringUtils.DIGIT; 264 timeProvider = timeProvider == null ? TimeProvider.INSTANCE : timeProvider; 265 266 // States: 267 // S01: Looking for Y(S02) or T(S07). 268 // S02: Found Y, looking for Y(S02)/-(S03)/T(S07). 269 // S03: Found -, looking for M(S04). 270 // S04: Found M, looking for M(S04)/-(S05)/T(S07). 271 // S05: Found -, looking for D(S10). 272 // S06 Found D, looking for D(S06)/T(S07). 273 // S07: Found T, looking for h(S08)/Z(S15)/+(S16)/-(S17). 274 // S08: Found h, looking for h(S08)/:(S09)/Z(S15)/+(S16)/-(S17). 275 // S09: Found :, looking for m(S10). 276 // S10: Found m, looking for m(S10)/:(S11)/Z(S15)/+(S16)/-(S17). 277 // S11: Found :, looking for s(S12). 278 // S12: Found s, looking for s(S12)/.(S13)/Z(S15)/+(S16)/-(S17). 279 // S13: Found ., looking for S(S14)/Z(S15)/+(S16)/-(S17). 280 // S14: Found S, looking for S(S14)/Z(S15)/+(S16)/-(S17). 281 // S15: Found Z. 282 // S16: Found +, looking for oh(S18). 283 // S17: Found -, looking for oh(S18). 284 // S18: Found oh, looking for oh(S18)/:(S19). 285 // S19: Found :, looking for om(S20). 286 // S20: Found om, looking for om(S20). 287 288 289 int year = 1, month = 1, day = 1, hour = 0, minute = 0, second = 0, nanos = 0, ohour = -1, ominute = -1; 290 boolean nego = false; // negative offset 291 boolean timeOnly = false; // Track if format started with "T" (time-only) 292 ZoneId zoneId = null; 293 var state = S1; 294 var mark = 0; 295 ChronoField precision = ChronoField.YEAR; // Track precision as we go 296 297 for (var i = 0; i < value.length(); i++) { 298 var c = value.charAt(i); 299 300 if (state == S1) { 301 // S01: Looking for Y(S02) or T(S07) 302 if (digit.contains(c)) { 303 mark = i; 304 state = S2; 305 } else if (c == 'T') { 306 timeOnly = true; // Mark as time-only format 307 state = S7; 308 } else { 309 throw bad(value, i); 310 } 311 } else if (state == S2) { 312 // S02: Found Y, looking for Y(S02)/-(S03)/T(S07) 313 if (digit.contains(c)) { 314 // Stay in S2 315 } else if (c == '-') { 316 year = parse(value, 4, mark, i, 0, 9999); 317 state = S3; 318 } else if (c == 'T') { 319 year = parse(value, 4, mark, i, 0, 9999); 320 state = S7; 321 } else if (c == 'Z') { 322 zoneId = ZoneId.of("Z"); 323 year = parse(value, 4, mark, i, 0, 9999); 324 state = S15; 325 } else if (c == '+') { 326 year = parse(value, 4, mark, i, 0, 9999); 327 nego = false; 328 state = S16; 329 } else { 330 throw bad(value, i); 331 } 332 } else if (state == S3) { 333 // S03: Found -, looking for M(S04) 334 if (digit.contains(c)) { 335 mark = i; 336 state = S4; 337 precision = ChronoField.MONTH_OF_YEAR; 338 } else { 339 throw bad(value, i); 340 } 341 } else if (state == S4) { 342 // S04: Found M, looking for M(S04)/-(S05)/T(S07) 343 if (digit.contains(c)) { 344 // Stay in S4 345 } else if (c == '-') { 346 month = parse(value, 2, mark, i, 1, 12); 347 state = S5; 348 } else if (c == 'T') { 349 month = parse(value, 2, mark, i, 1, 12); 350 state = S7; 351 } else if (c == 'Z') { 352 month = parse(value, 2, mark, i, 1, 12); 353 zoneId = ZoneId.of("Z"); 354 state = S15; 355 } else if (c == '+') { 356 month = parse(value, 2, mark, i, 1, 12); 357 nego = false; 358 state = S16; 359 } else { 360 throw bad(value, i); 361 } 362 } else if (state == S5) { 363 // S05: Found -, looking for D(S06) 364 if (digit.contains(c)) { 365 mark = i; 366 state = S6; 367 precision = ChronoField.DAY_OF_MONTH; 368 } else { 369 throw bad(value, i); 370 } 371 } else if (state == S6) { 372 // S06: Found D, looking for D(S06)/T(S07) 373 if (digit.contains(c)) { 374 // Stay in S6 375 } else if (c == 'T') { 376 day = parse(value, 2, mark, i, 1, 31); 377 state = S7; 378 } else if (c == 'Z') { 379 day = parse(value, 2, mark, i, 1, 31); 380 zoneId = ZoneId.of("Z"); 381 state = S15; 382 } else if (c == '+') { 383 day = parse(value, 2, mark, i, 1, 31); 384 nego = false; 385 state = S16; 386 } else if (c == '-') { 387 day = parse(value, 2, mark, i, 1, 31); 388 nego = true; 389 state = S17; 390 } else { 391 throw bad(value, i); 392 } 393 } else if (state == S7) { 394 // S07: Found T, looking for h(S08)/Z(S15)/+(S16)/-(S17) 395 if (digit.contains(c)) { 396 mark = i; 397 state = S8; 398 precision = ChronoField.HOUR_OF_DAY; 399 } else if (c == 'Z') { 400 zoneId = ZoneId.of("Z"); 401 if (timeOnly) { 402 precision = ChronoField.HOUR_OF_DAY; 403 } 404 state = S15; 405 } else if (c == '+') { 406 nego = false; 407 if (timeOnly) { 408 precision = ChronoField.HOUR_OF_DAY; 409 } 410 state = S16; 411 } else if (c == '-') { 412 nego = true; 413 if (timeOnly) { 414 precision = ChronoField.HOUR_OF_DAY; 415 } 416 state = S17; 417 } else { 418 throw bad(value, i); 419 } 420 } else if (state == S8) { 421 // S08: Found h, looking for h(S08)/:(S09)/Z(S15)/+(S16)/-(S17) 422 if (digit.contains(c)) { 423 // Stay in S8 424 } else if (c == ':') { 425 hour = parse(value, 2, mark, i, 0, 23); 426 state = S9; 427 } else if (c == 'Z') { 428 hour = parse(value, 2, mark, i, 0, 23); 429 zoneId = ZoneId.of("Z"); 430 state = S15; 431 } else if (c == '+') { 432 hour = parse(value, 2, mark, i, 0, 23); 433 nego = false; 434 state = S16; 435 } else if (c == '-') { 436 hour = parse(value, 2, mark, i, 0, 23); 437 nego = true; 438 state = S17; 439 } else { 440 throw bad(value, i); 441 } 442 } else if (state == S9) { 443 // S09: Found :, looking for m(S10) 444 if (digit.contains(c)) { 445 mark = i; 446 state = S10; 447 precision = ChronoField.MINUTE_OF_HOUR; 448 } else { 449 throw bad(value, i); 450 } 451 } else if (state == S10) { 452 // S10: Found m, looking for m(S10)/:(S11)/Z(S15)/+(S16)/-(S17) 453 if (digit.contains(c)) { 454 // Stay in S10 455 } else if (c == ':') { 456 minute = parse(value, 2, mark, i, 0, 59); 457 state = S11; 458 } else if (c == 'Z') { 459 minute = parse(value, 2, mark, i, 0, 59); 460 zoneId = ZoneId.of("Z"); 461 state = S15; 462 } else if (c == '+') { 463 minute = parse(value, 2, mark, i, 0, 59); 464 nego = false; 465 state = S16; 466 } else if (c == '-') { 467 minute = parse(value, 2, mark, i, 0, 59); 468 nego = true; 469 state = S17; 470 } else { 471 throw bad(value, i); 472 } 473 } else if (state == S11) { 474 // S11: Found :, looking for s(S12) 475 if (digit.contains(c)) { 476 mark = i; 477 state = S12; 478 precision = ChronoField.SECOND_OF_MINUTE; 479 } else { 480 throw bad(value, i); 481 } 482 } else if (state == S12) { 483 // S12: Found s, looking for s(S12)/.(S13)/Z(S15)/+(S16)/-(S17) 484 if (digit.contains(c)) { 485 // Stay in S12 486 } else if (c == '.' || c == ',') { 487 second = parse(value, 2, mark, i, 0, 59); 488 state = S13; 489 // Precision will be set based on number of fractional digits 490 } else if (c == 'Z') { 491 second = parse(value, 2, mark, i, 0, 59); 492 zoneId = ZoneId.of("Z"); 493 state = S15; 494 } else if (c == '+') { 495 second = parse(value, 2, mark, i, 0, 59); 496 nego = false; 497 state = S16; 498 } else if (c == '-') { 499 second = parse(value, 2, mark, i, 0, 59); 500 nego = true; 501 state = S17; 502 } else { 503 throw bad(value, i); 504 } 505 } else if (state == S13) { 506 // S13: Found . or ,, looking for S(S14)/Z(S15)/+(S16)/-(S17) 507 if (digit.contains(c)) { 508 mark = i; 509 state = S14; 510 } else if (c == 'Z') { 511 zoneId = ZoneId.of("Z"); 512 state = S15; 513 } else if (c == '+') { 514 nego = false; 515 state = S16; 516 } else if (c == '-') { 517 nego = true; 518 state = S17; 519 } else { 520 throw bad(value, i); 521 } 522 } else if (state == S14) { 523 // S14: Found S, looking for S(S14)/Z(S15)/+(S16)/-(S17) 524 if (digit.contains(c)) { 525 // Stay in S14 526 } else if (c == 'Z') { 527 nanos = parseNanos(value, mark, i); 528 zoneId = ZoneId.of("Z"); 529 // Set precision based on number of fractional digits: 1-3 = milliseconds, 4-9 = nanoseconds 530 var digitCount = i - mark; 531 precision = (digitCount <= 3) ? ChronoField.MILLI_OF_SECOND : ChronoField.NANO_OF_SECOND; 532 state = S15; 533 } else if (c == '+') { 534 nanos = parseNanos(value, mark, i); 535 // Set precision based on number of fractional digits: 1-3 = milliseconds, 4-9 = nanoseconds 536 var digitCount = i - mark; 537 precision = (digitCount <= 3) ? ChronoField.MILLI_OF_SECOND : ChronoField.NANO_OF_SECOND; 538 nego = false; 539 state = S16; 540 } else if (c == '-') { 541 nanos = parseNanos(value, mark, i); 542 // Set precision based on number of fractional digits: 1-3 = milliseconds, 4-9 = nanoseconds 543 var digitCount = i - mark; 544 precision = (digitCount <= 3) ? ChronoField.MILLI_OF_SECOND : ChronoField.NANO_OF_SECOND; 545 nego = true; 546 state = S17; 547 } else { 548 throw bad(value, i); 549 } 550 } else if (state == S15) { 551 // Shouldn't find anything after Z 552 throw bad(value, i); 553 } else if (state == S16) { 554 // S16: Found +, looking for oh(S18) 555 if (digit.contains(c)) { 556 mark = i; 557 state = S18; 558 } else { 559 throw bad(value, i); 560 } 561 } else if (state == S17) { 562 // S17: Found -, looking for oh(S18) 563 if (digit.contains(c)) { 564 mark = i; 565 state = S18; 566 } else { 567 throw bad(value, i); 568 } 569 } else if (state == S18) { 570 // S18: Found oh, looking for oh(S18)/:(S19)/end 571 if (digit.contains(c)) { 572 // Stay in S18 573 } else if (c == ':') { 574 ohour = parse(value, 2, mark, i, 0, 18); 575 state = S19; 576 } else { 577 throw bad(value, i); 578 } 579 // If we reach end of string, ohour is complete (2 digits) 580 } else if (state == S19) { 581 // S19: Found :, looking for om(S20) 582 if (digit.contains(c)) { 583 mark = i; 584 state = S20; 585 } else { 586 throw bad(value, i); 587 } 588 } else /* (state == S20) */ { 589 // S20: Found om, looking for om(S20) 590 if (digit.contains(c)) { 591 // Stay in S20 592 } else { 593 throw bad(value, i); 594 } 595 } 596 } 597 598 var end = value.length(); // end is exclusive (one past last character) 599 if (state.isAny(S1, S3, S5, S7, S9, S11, S13, S16, S17, S19)) { 600 throw bad(value, end - 1); 601 } else if (state == S2) { 602 // S02: Found Y, looking for Y(S02)/-(S03)/T(S07). 603 year = parse(value, 4, mark, end, 0, 9999); 604 precision = ChronoField.YEAR; 605 } else if (state == S4) { 606 // S04: Found M, looking for M(S04)/-(S05)/T(S07). 607 month = parse(value, 2, mark, end, 1, 12); 608 precision = ChronoField.MONTH_OF_YEAR; 609 } else if (state == S6) { 610 // S06 Found D, looking for D(S06)/T(S07). 611 day = parse(value, 2, mark, end, 1, 31); 612 precision = ChronoField.DAY_OF_MONTH; 613 } else if (state == S8) { 614 // S08: Found h, looking for h(S08)/:(S09)/Z(S15)/+(S16)/-(S17). 615 hour = parse(value, 2, mark, end, 0, 23); 616 precision = ChronoField.HOUR_OF_DAY; 617 } else if (state == S10) { 618 // S10: Found m, looking for m(S10)/:(S11)/Z(S15)/+(S16)/-(S17). 619 minute = parse(value, 2, mark, end, 0, 59); 620 precision = ChronoField.MINUTE_OF_HOUR; 621 } else if (state == S12) { 622 // S12: Found s, looking for s(S12)/.(S13)/Z(S15)/+(S16)/-(S17). 623 second = parse(value, 2, mark, end, 0, 59); 624 precision = ChronoField.SECOND_OF_MINUTE; 625 } else if (state == S14) { 626 // S14: Found S, looking for S(S14)/Z(S15)/+(S16)/-(S17). 627 nanos = parseNanos(value, mark, end); 628 // Set precision based on number of digits: 1-3 = milliseconds, 4-9 = nanoseconds 629 var digitCount = end - mark; 630 precision = (digitCount <= 3) ? ChronoField.MILLI_OF_SECOND : ChronoField.NANO_OF_SECOND; 631 } else if (state == S15) { 632 // S15: Found Z. 633 } else if (state == S18) { 634 // S18: Found oh, looking for oh(S18)/:(S19). 635 // Check if we have 2 digits (+hh) or 4 digits (+hhmm) 636 if (end - mark == 2) { 637 ohour = parse(value, 2, mark, end, 0, 18); 638 } else if (end - mark == 4) { 639 // +hhmm format: parse hours from mark to mark+2, minutes from mark+2 to end 640 ohour = parse(value, 2, mark, mark + 2, 0, 18); 641 ominute = parse(value, 2, mark + 2, end, 0, 59); 642 } else { 643 throw bad(value, mark); 644 } 645 } else /* (state == S20) */ { 646 // S20: Found om, looking for om(S20). 647 ominute = parse(value, 2, mark, end, 0, 59); 648 } 649 650 // Build ZoneId if we have offset information 651 if (zoneId == null) { 652 if (ohour >= 0) { 653 if (ominute >= 0) { 654 // If negative offset, both hours and minutes must be negative 655 var offset = ZoneOffset.ofHoursMinutes(nego ? -ohour : ohour, nego ? -ominute : ominute); 656 zoneId = offset; 657 } else { 658 var offset = ZoneOffset.ofHours(nego ? -ohour : ohour); 659 zoneId = offset; 660 } 661 } 662 } 663 664 // Use provided default zone if no zone specified, otherwise use system default 665 if (zoneId == null) { 666 zoneId = defaultZoneId != null ? defaultZoneId : timeProvider.getSystemDefaultZoneId(); 667 } 668 669 // Construct ZonedDateTime from parsed values 670 // Default values for missing components 671 // For time-only formats (started with "T"), use current date 672 // For date formats, default to 1/1/1 673 if (timeOnly) { 674 // Time-only format: use current year/month/day 675 var now = timeProvider.now(zoneId); 676 year = now.getYear(); 677 month = now.getMonthValue(); 678 day = now.getDayOfMonth(); 679 } 680 681 var localDateTime = LocalDateTime.of(year, month, day, hour, minute, second); 682 if (nanos > 0) { 683 localDateTime = localDateTime.plusNanos(nanos); 684 } 685 686 var zdt = ZonedDateTime.of(localDateTime, zoneId); 687 688 // Return GranularZonedDateTime with the determined precision 689 return new GranularZonedDateTime(zdt, precision); 690 } 691 692 /** 693 * Creates a GranularZonedDateTime from a ZonedDateTime with the specified precision. 694 * 695 * <p> 696 * This is the most direct way to create a GranularZonedDateTime when you already have 697 * a ZonedDateTime and want to specify its precision. 698 * 699 * @param date The ZonedDateTime value. 700 * @param precision The precision of the time value. 701 * @return A new GranularZonedDateTime instance. 702 * @throws IllegalArgumentException if date or precision is null. 703 */ 704 public static GranularZonedDateTime of(ZonedDateTime date, ChronoField precision) { 705 return new GranularZonedDateTime(date, precision); 706 } 707 708 709 private static DateTimeParseException bad(String s, int pos) { 710 return new DateTimeParseException("Invalid ISO8601 timestamp", s, pos); 711 } 712 713 private static int parse(String s, int chars, int pos, int end, int min, int max) { 714 if (end-pos != chars) throw bad(s, pos); 715 var i = Integer.parseInt(s, pos, end, 10); 716 if (i < min || i > max) throw bad(s, pos); 717 return i; 718 } 719 720 private static int parseNanos(String s, int pos, int end) { 721 var len = end - pos; // Length of the substring being parsed 722 if (len > 9) { 723 throw bad(s, pos); 724 } 725 var n = Integer.parseInt(s, pos, end, 10); 726 // Convert to nanoseconds based on number of digits 727 // 1 digit = hundreds of milliseconds, 2 = tens, 3 = milliseconds, etc. 728 if (len == 1) return n * 100000000; 729 if (len == 2) return n * 10000000; 730 if (len == 3) return n * 1000000; 731 if (len == 4) return n * 100000; 732 if (len == 5) return n * 10000; 733 if (len == 6) return n * 1000; 734 if (len == 7) return n * 100; 735 if (len == 8) return n * 10; 736 return n; 737 } 738 739 /** 740 * Converts a ChronoField to its corresponding ChronoUnit. 741 * 742 * <p> 743 * This method provides a mapping from date/time fields to time units. 744 * Not all ChronoField values have direct ChronoUnit equivalents. 745 * 746 * <p> 747 * Supported fields: 748 * <ul> 749 * <li>{@link ChronoField#YEAR} → {@link ChronoUnit#YEARS} 750 * <li>{@link ChronoField#MONTH_OF_YEAR} → {@link ChronoUnit#MONTHS} 751 * <li>{@link ChronoField#DAY_OF_MONTH} → {@link ChronoUnit#DAYS} 752 * <li>{@link ChronoField#HOUR_OF_DAY} → {@link ChronoUnit#HOURS} 753 * <li>{@link ChronoField#MINUTE_OF_HOUR} → {@link ChronoUnit#MINUTES} 754 * <li>{@link ChronoField#SECOND_OF_MINUTE} → {@link ChronoUnit#SECONDS} 755 * <li>{@link ChronoField#MILLI_OF_SECOND} → {@link ChronoUnit#MILLIS} 756 * <li>{@link ChronoField#NANO_OF_SECOND} → {@link ChronoUnit#NANOS} 757 * </ul> 758 * 759 * @param field The ChronoField to convert 760 * @return The corresponding ChronoUnit, or null if no direct mapping exists 761 */ 762 private static ChronoUnit toChronoUnit(ChronoField field) { 763 return switch (field) { 764 case YEAR -> ChronoUnit.YEARS; 765 case MONTH_OF_YEAR -> ChronoUnit.MONTHS; 766 case DAY_OF_MONTH -> ChronoUnit.DAYS; 767 case HOUR_OF_DAY -> ChronoUnit.HOURS; 768 case MINUTE_OF_HOUR -> ChronoUnit.MINUTES; 769 case SECOND_OF_MINUTE -> ChronoUnit.SECONDS; 770 case MILLI_OF_SECOND -> ChronoUnit.MILLIS; 771 case NANO_OF_SECOND -> ChronoUnit.NANOS; 772 default -> null; 773 }; 774 } 775 776 /** The ZonedDateTime value */ 777 public final ZonedDateTime zdt; 778 779 /** The precision of this time value */ 780 public final ChronoField precision; 781 782 /** 783 * Constructor. 784 * 785 * @param zdt The ZonedDateTime value. 786 * @param precision The precision of this time value. 787 */ 788 public GranularZonedDateTime(ZonedDateTime zdt, ChronoField precision) { 789 this.zdt = zdt; 790 this.precision = precision; 791 } 792 793 /** 794 * Creates a copy of this object. 795 * 796 * @return A new GranularZonedDateTime with the same values. 797 */ 798 public GranularZonedDateTime copy() { 799 return new GranularZonedDateTime(zdt, precision); 800 } 801 802 /** 803 * Returns the ZonedDateTime value. 804 * 805 * @return The ZonedDateTime value. 806 */ 807 public ZonedDateTime getZonedDateTime() { return zdt; } 808 809 /** 810 * Returns the precision of this time value. 811 * 812 * <p> 813 * The precision indicates the finest granularity of the time value, which determines 814 * how the value was parsed or created. For example: 815 * <ul> 816 * <li>{@link ChronoField#YEAR} - Year precision (e.g., "2011") 817 * <li>{@link ChronoField#MONTH_OF_YEAR} - Month precision (e.g., "2011-01") 818 * <li>{@link ChronoField#DAY_OF_MONTH} - Day precision (e.g., "2011-01-15") 819 * <li>{@link ChronoField#HOUR_OF_DAY} - Hour precision (e.g., "2011-01-15T12") 820 * <li>{@link ChronoField#MINUTE_OF_HOUR} - Minute precision (e.g., "2011-01-15T12:30") 821 * <li>{@link ChronoField#SECOND_OF_MINUTE} - Second precision (e.g., "2011-01-15T12:30:45") 822 * <li>{@link ChronoField#MILLI_OF_SECOND} - Millisecond precision (e.g., "2011-01-15T12:30:45.123") 823 * <li>{@link ChronoField#NANO_OF_SECOND} - Nanosecond precision (e.g., "2011-01-15T12:30:45.123456789") 824 * </ul> 825 * 826 * @return The precision of this time value. 827 */ 828 public ChronoField getPrecision() { return precision; } 829 830 @Override 831 public String toString() { 832 return zdt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + "(" + precision + ")"; 833 } 834 835 /** 836 * Rolls this time value by the specified amount using the specified field. 837 * 838 * <p> 839 * This method creates a new GranularZonedDateTime by adding the specified amount to the 840 * specified field. The precision of the returned object remains the same as this object. 841 * 842 * <h5 class='section'>Supported Fields:</h5> 843 * <ul> 844 * <li>{@link ChronoField#YEAR} 845 * <li>{@link ChronoField#MONTH_OF_YEAR} 846 * <li>{@link ChronoField#DAY_OF_MONTH} 847 * <li>{@link ChronoField#HOUR_OF_DAY} 848 * <li>{@link ChronoField#MINUTE_OF_HOUR} 849 * <li>{@link ChronoField#SECOND_OF_MINUTE} 850 * <li>{@link ChronoField#MILLI_OF_SECOND} 851 * <li>{@link ChronoField#NANO_OF_SECOND} 852 * </ul> 853 * 854 * <h5 class='section'>Example:</h5> 855 * <p class='bjava'> 856 * <jc>// Create a datetime with hour precision</jc> 857 * GranularZonedDateTime <jv>gdt</jv> = GranularZonedDateTime.<jsm>of</jsm>(<js>"2011-01-15T12Z"</js>); 858 * <jc>// Roll forward by 2 hours</jc> 859 * <jv>gdt</jv> = <jv>gdt</jv>.<jsm>roll</jsm>(<jv>ChronoField</jv>.<jf>HOUR_OF_DAY</jf>, 2); 860 * <jc>// Result: 2011-01-15T14:00:00Z (precision still HOUR_OF_DAY)</jc> 861 * </p> 862 * 863 * @param field The field to roll by. Must be one of the supported fields listed above. 864 * @param amount The amount to roll by. Positive values roll forward, negative values roll backward. 865 * @return A new GranularZonedDateTime with the rolled value. 866 * @throws IllegalArgumentException If the field is not supported. 867 */ 868 public GranularZonedDateTime roll(ChronoField field, int amount) { 869 var unit = toChronoUnit(field); 870 assertArg(unit != null, "Unsupported roll field: {0}", field); 871 var newZdt = zdt.plus(amount, unit); 872 return new GranularZonedDateTime(newZdt, precision); 873 } 874 875 /** 876 * Rolls this time value by the specified amount using the current precision. 877 * 878 * <p> 879 * This is a convenience method that calls {@link #roll(ChronoField, int)} using this 880 * object's precision field. 881 * 882 * <h5 class='section'>Example:</h5> 883 * <p class='bjava'> 884 * <jc>// Create a datetime with year precision</jc> 885 * GranularZonedDateTime <jv>gdt</jv> = GranularZonedDateTime.<jsm>of</jsm>(<js>"2011"</js>); 886 * <jc>// Roll forward by 1 (using YEAR precision)</jc> 887 * <jv>gdt</jv> = <jv>gdt</jv>.<jsm>roll</jsm>(1); 888 * <jc>// Result: 2012-01-01T00:00:00 (precision still YEAR)</jc> 889 * </p> 890 * 891 * @param amount The amount to roll by. Positive values roll forward, negative values roll backward. 892 * @return A new GranularZonedDateTime with the rolled value. 893 */ 894 public GranularZonedDateTime roll(int amount) { 895 return roll(precision, amount); 896 } 897}