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