1 package org.unicode.cldr.util; 2 3 import com.ibm.icu.impl.Relation; 4 import com.ibm.icu.impl.Row; 5 import com.ibm.icu.impl.Row.R3; 6 import com.ibm.icu.util.Output; 7 import java.util.ArrayList; 8 import java.util.Arrays; 9 import java.util.Collections; 10 import java.util.EnumMap; 11 import java.util.EnumSet; 12 import java.util.LinkedHashSet; 13 import java.util.List; 14 import java.util.Map.Entry; 15 import java.util.Set; 16 import java.util.TreeSet; 17 18 public class DayPeriodInfo { 19 public static final int HOUR = 60 * 60 * 1000; 20 public static final int MIDNIGHT = 0; 21 public static final int NOON = 12 * HOUR; 22 public static final int DAY_LIMIT = 24 * HOUR; 23 24 public enum Type { 25 format("format"), 26 selection("stand-alone"); 27 public final String pathValue; 28 Type(String _pathValue)29 private Type(String _pathValue) { 30 pathValue = _pathValue; 31 } 32 fromString(String source)33 public static Type fromString(String source) { 34 return selection.pathValue.equals(source) ? selection : Type.valueOf(source); 35 } 36 } 37 38 public static class Span implements Comparable<Span> { 39 public final int start; 40 public final int end; 41 public final boolean includesEnd; 42 public final DayPeriod dayPeriod; 43 Span(int start, int end, DayPeriod dayPeriod)44 public Span(int start, int end, DayPeriod dayPeriod) { 45 this.start = start; 46 this.end = end; 47 this.includesEnd = start == end; 48 this.dayPeriod = dayPeriod; 49 } 50 51 @Override compareTo(Span o)52 public int compareTo(Span o) { 53 int diff = start - o.start; 54 if (diff != 0) { 55 return diff; 56 } 57 diff = end - o.end; 58 if (diff != 0) { 59 return diff; 60 } 61 // because includesEnd is determined by the above, we're done 62 return 0; 63 } 64 contains(int millisInDay)65 public boolean contains(int millisInDay) { 66 return start <= millisInDay && (millisInDay < end || millisInDay == end && includesEnd); 67 } 68 69 /** 70 * Returns end, but if not includesEnd, adjusted down by one. 71 * 72 * @return 73 */ getAdjustedEnd()74 public int getAdjustedEnd() { 75 return includesEnd ? end : end - 1; 76 } 77 78 @Override equals(Object obj)79 public boolean equals(Object obj) { 80 Span other = (Span) obj; 81 return start == other.start && end == other.end; 82 // because includesEnd is determined by the above, we're done 83 } 84 85 @Override hashCode()86 public int hashCode() { 87 return start * 37 + end; 88 } 89 90 @Override toString()91 public String toString() { 92 return dayPeriod + ":" + toStringPlain(); 93 } 94 toStringPlain()95 public String toStringPlain() { 96 return formatTime(start) + " – " + formatTime(end) + (includesEnd ? "" : "⁻"); 97 } 98 } 99 100 public enum DayPeriod { 101 // fixed 102 midnight(MIDNIGHT, MIDNIGHT), 103 am(MIDNIGHT, NOON), 104 noon(NOON, NOON), 105 pm(NOON, DAY_LIMIT), 106 // flexible 107 morning1, 108 morning2, 109 afternoon1, 110 afternoon2, 111 evening1, 112 evening2, 113 night1, 114 night2; 115 116 public final Span span; 117 DayPeriod(int start, int end)118 private DayPeriod(int start, int end) { 119 span = new Span(start, end, this); 120 } 121 DayPeriod()122 private DayPeriod() { 123 span = null; 124 } 125 fromString(String value)126 public static DayPeriod fromString(String value) { 127 return valueOf(value); 128 } 129 isFixed()130 public boolean isFixed() { 131 return span != null; 132 } 133 } 134 135 // the arrays must be in sorted order. First must have start= zero. Last must have end = 136 // DAY_LIMIT (and !includesEnd) 137 // each of these will have the same length, and correspond. 138 private final Span[] spans; 139 private final DayPeriodInfo.DayPeriod[] dayPeriods; 140 final Relation<DayPeriod, Span> dayPeriodsToSpans = 141 Relation.of(new EnumMap<DayPeriod, Set<Span>>(DayPeriod.class), LinkedHashSet.class); 142 143 public static class Builder { 144 TreeSet<Span> info = new TreeSet<>(); 145 146 // TODO add rule test that they can't span same 12 hour time. 147 add( DayPeriodInfo.DayPeriod dayPeriod, int start, boolean includesStart, int end, boolean includesEnd)148 public DayPeriodInfo.Builder add( 149 DayPeriodInfo.DayPeriod dayPeriod, 150 int start, 151 boolean includesStart, 152 int end, 153 boolean includesEnd) { 154 if (dayPeriod == null 155 || start < 0 156 || start > end 157 || end > DAY_LIMIT 158 || end - start > NOON) { // the span can't exceed 12 hours 159 throw new IllegalArgumentException("Bad data"); 160 } 161 Span span = new Span(start, end, dayPeriod); 162 boolean didntContain = info.add(span); 163 if (!didntContain) { 164 throw new IllegalArgumentException("Duplicate data: " + span); 165 } 166 return this; 167 } 168 finish(String[] locales)169 public DayPeriodInfo finish(String[] locales) { 170 DayPeriodInfo result = new DayPeriodInfo(info, locales); 171 info.clear(); 172 return result; 173 } 174 contains(DayPeriod dayPeriod)175 public boolean contains(DayPeriod dayPeriod) { 176 for (Span span : info) { 177 if (span.dayPeriod == dayPeriod) { 178 return true; 179 } 180 } 181 return false; 182 } 183 } 184 DayPeriodInfo(TreeSet<Span> info, String[] locales)185 private DayPeriodInfo(TreeSet<Span> info, String[] locales) { 186 int len = info.size(); 187 spans = info.toArray(new Span[len]); 188 List<DayPeriod> tempPeriods = new ArrayList<>(); 189 // check data 190 if (len != 0) { 191 Span last = spans[0]; 192 tempPeriods.add(last.dayPeriod); 193 dayPeriodsToSpans.put(last.dayPeriod, last); 194 if (last.start != MIDNIGHT) { 195 throw new IllegalArgumentException("Doesn't start at 0:00)."); 196 } 197 for (int i = 1; i < len; ++i) { 198 Span current = spans[i]; 199 if (current.start != current.end && last.start != last.end) { 200 if (current.start != last.end) { 201 throw new IllegalArgumentException( 202 "Gap or overlapping times:\t" 203 + current 204 + "\t" 205 + last 206 + "\t" 207 + Arrays.asList(locales)); 208 } 209 } 210 tempPeriods.add(current.dayPeriod); 211 dayPeriodsToSpans.put(current.dayPeriod, current); 212 last = current; 213 } 214 if (last.end != DAY_LIMIT) { 215 throw new IllegalArgumentException("Doesn't reach 24:00)."); 216 } 217 } 218 dayPeriods = tempPeriods.toArray(new DayPeriod[len]); 219 dayPeriodsToSpans.freeze(); 220 // add an extra check to make sure that periods are unique over 12 hour spans 221 for (Entry<DayPeriod, Set<Span>> entry : dayPeriodsToSpans.keyValuesSet()) { 222 DayPeriod dayPeriod = entry.getKey(); 223 Set<Span> spanSet = entry.getValue(); 224 if (spanSet.size() > 0) { 225 for (Span span : spanSet) { 226 int start = span.start % NOON; 227 int end = span.getAdjustedEnd() % NOON; 228 for (Span span2 : spanSet) { 229 if (span2 == span) { 230 continue; 231 } 232 // if there is overlap when mapped to 12 hours... 233 int start2 = span2.start % NOON; 234 int end2 = span2.getAdjustedEnd() % NOON; 235 // disjoint if e1 < s2 || e2 < s1 236 if (start >= end2 && start2 >= end) { 237 throw new IllegalArgumentException( 238 "Overlapping times for " 239 + dayPeriod 240 + ":\t" 241 + span 242 + "\t" 243 + span2 244 + "\t" 245 + Arrays.asList(locales)); 246 } 247 } 248 } 249 } 250 } 251 } 252 253 /** 254 * Return the start (in millis) of the first matching day period, or -1 if no match, 255 * 256 * @param dayPeriod 257 * @return seconds in day 258 */ getFirstStartTime(DayPeriodInfo.DayPeriod dayPeriod)259 public int getFirstStartTime(DayPeriodInfo.DayPeriod dayPeriod) { 260 for (int i = 0; i < spans.length; ++i) { 261 if (spans[i].dayPeriod == dayPeriod) { 262 return spans[i].start; 263 } 264 } 265 return -1; 266 } 267 268 /** 269 * Return the start, end, and whether the start is included. 270 * 271 * @param dayPeriod 272 * @return start,end,includesStart,period 273 */ getFirstDayPeriodInfo(DayPeriodInfo.DayPeriod dayPeriod)274 public R3<Integer, Integer, Boolean> getFirstDayPeriodInfo(DayPeriodInfo.DayPeriod dayPeriod) { 275 Span span = getFirstDayPeriodSpan(dayPeriod); 276 if (span == null) { 277 return null; 278 } 279 return Row.of(span.start, span.end, true); 280 } 281 getFirstDayPeriodSpan(DayPeriodInfo.DayPeriod dayPeriod)282 public Span getFirstDayPeriodSpan(DayPeriodInfo.DayPeriod dayPeriod) { 283 switch (dayPeriod) { 284 case am: 285 return DayPeriod.am.span; 286 case pm: 287 return DayPeriod.pm.span; 288 default: 289 Set<Span> spanList = dayPeriodsToSpans.get(dayPeriod); 290 return spanList == null ? null : dayPeriodsToSpans.get(dayPeriod).iterator().next(); 291 } 292 } 293 getDayPeriodSpans(DayPeriodInfo.DayPeriod dayPeriod)294 public Set<Span> getDayPeriodSpans(DayPeriodInfo.DayPeriod dayPeriod) { 295 switch (dayPeriod) { 296 case am: 297 return Collections.singleton(DayPeriod.am.span); 298 case pm: 299 return Collections.singleton(DayPeriod.pm.span); 300 default: 301 return dayPeriodsToSpans.get(dayPeriod); 302 } 303 } 304 305 /** 306 * Returns the day period for the time. 307 * 308 * @param millisInDay If not (millisInDay > 0 && The millisInDay < DAY_LIMIT) throws exception. 309 * @return corresponding day period 310 */ getDayPeriod(int millisInDay)311 public DayPeriodInfo.DayPeriod getDayPeriod(int millisInDay) { 312 if (millisInDay < MIDNIGHT) { 313 throw new IllegalArgumentException("millisInDay too small"); 314 } else if (millisInDay >= DAY_LIMIT) { 315 throw new IllegalArgumentException("millisInDay too big"); 316 } 317 for (int i = 0; i < spans.length; ++i) { 318 if (spans[i].contains(millisInDay)) { 319 return spans[i].dayPeriod; 320 } 321 } 322 throw new IllegalArgumentException("internal error"); 323 } 324 325 /** 326 * Returns the number of periods in the day 327 * 328 * @return 329 */ getPeriodCount()330 public int getPeriodCount() { 331 return spans.length; 332 } 333 334 /** 335 * For the nth period in the day, returns the start, whether the start is included, and the 336 * period ID. 337 * 338 * @param index 339 * @return data 340 */ getPeriod(int index)341 public Row.R3<Integer, Boolean, DayPeriod> getPeriod(int index) { 342 return Row.of(getSpan(index).start, true, getSpan(index).dayPeriod); 343 } 344 getSpan(int index)345 public Span getSpan(int index) { 346 return spans[index]; 347 } 348 getPeriods()349 public List<DayPeriod> getPeriods() { 350 return Arrays.asList(dayPeriods); 351 } 352 353 @Override toString()354 public String toString() { 355 return dayPeriodsToSpans.values().toString(); 356 } 357 toString(DayPeriod dayPeriod)358 public String toString(DayPeriod dayPeriod) { 359 switch (dayPeriod) { 360 case midnight: 361 return "00:00"; 362 case noon: 363 return "12:00"; 364 case am: 365 return "00:00 – 12:00⁻"; 366 case pm: 367 return "12:00 – 24:00⁻"; 368 default: 369 break; 370 } 371 StringBuilder result = new StringBuilder(); 372 Set<Span> set = dayPeriodsToSpans.get(dayPeriod); 373 if (set != null) { 374 for (Span span : set) { 375 if (span != null) { 376 if (result.length() != 0) { 377 result.append("; "); 378 } 379 result.append(span.toStringPlain()); 380 } 381 } 382 } 383 return result.toString(); 384 } 385 formatTime(long time)386 public static String formatTime(long time) { 387 long minutes = time / (60 * 1000); 388 long hours = minutes / 60; 389 minutes -= hours * 60; 390 return String.format("%02d:%02d", hours, minutes); 391 } 392 393 // Day periods that are allowed to collide 394 private static final EnumMap<DayPeriod, EnumSet<DayPeriod>> allowableCollisions = 395 new EnumMap<>(DayPeriod.class); 396 397 static { allowableCollisions.put(DayPeriod.am, EnumSet.of(DayPeriod.morning1, DayPeriod.morning2))398 allowableCollisions.put(DayPeriod.am, EnumSet.of(DayPeriod.morning1, DayPeriod.morning2)); allowableCollisions.put( DayPeriod.pm, EnumSet.of( DayPeriod.afternoon1, DayPeriod.afternoon2, DayPeriod.evening1, DayPeriod.evening2))399 allowableCollisions.put( 400 DayPeriod.pm, 401 EnumSet.of( 402 DayPeriod.afternoon1, 403 DayPeriod.afternoon2, 404 DayPeriod.evening1, 405 DayPeriod.evening2)); 406 } 407 408 /** 409 * Test if there is a problem with dayPeriod1 and dayPeriod2 having the same localization. 410 * 411 * @param type1 412 * @param dayPeriod1 413 * @param type2 TODO 414 * @param dayPeriod2 415 * @param sampleError TODO 416 * @return 417 */ collisionIsError( DayPeriodInfo.Type type1, DayPeriod dayPeriod1, Type type2, DayPeriod dayPeriod2, Output<Integer> sampleError)418 public boolean collisionIsError( 419 DayPeriodInfo.Type type1, 420 DayPeriod dayPeriod1, 421 Type type2, 422 DayPeriod dayPeriod2, 423 Output<Integer> sampleError) { 424 if (dayPeriod1 == dayPeriod2) { 425 return false; 426 } 427 if ((allowableCollisions.containsKey(dayPeriod1) 428 && allowableCollisions.get(dayPeriod1).contains(dayPeriod2)) 429 || (allowableCollisions.containsKey(dayPeriod2) 430 && allowableCollisions.get(dayPeriod2).contains(dayPeriod1))) { 431 return false; 432 } 433 434 // Hack for French night1, CLDR-17132 for better fix 435 // Let night1 have the same name as morning1/am if night1 starts at 00:00 436 if ((dayPeriod1 == DayPeriod.night1 437 && (dayPeriod2 == DayPeriod.morning1 || dayPeriod2 == DayPeriod.am)) 438 || (dayPeriod2 == DayPeriod.night1 439 && (dayPeriod1 == DayPeriod.morning1 || dayPeriod1 == DayPeriod.am))) { 440 if (dayPeriodsToSpans.get(DayPeriod.night1).size() == 1) { 441 for (Span s : dayPeriodsToSpans.get(DayPeriod.night1)) { 442 if (s.start == MIDNIGHT) { 443 return false; 444 } 445 } 446 } 447 } 448 449 // Hack for fil evening1/night1, CLDR-17139 for better fix 450 // Let night1 have the same name as evening1 if night1 ends at 24:00 451 if ((dayPeriod1 == DayPeriod.night1 && dayPeriod2 == DayPeriod.evening1) 452 || (dayPeriod2 == DayPeriod.night1 && dayPeriod1 == DayPeriod.evening1)) { 453 if (dayPeriodsToSpans.get(DayPeriod.night1).size() == 1) { 454 for (Span s : dayPeriodsToSpans.get(DayPeriod.night1)) { 455 if (s.end == DAY_LIMIT) { 456 return false; 457 } 458 } 459 } 460 } 461 462 // Fix for CLDR-16933, for format types only 463 // Let midnight match a non-fixed period that starts at, ends at, or contains midnight (both 464 // versions); 465 // Let noon match a non-fixed period that starts at, ends at, or contains noon (or just 466 // before noon); 467 if (type1 == Type.format && type2 == Type.format) { 468 if (dayPeriod1 == DayPeriod.midnight && !dayPeriod2.isFixed()) { 469 for (Span s : dayPeriodsToSpans.get(dayPeriod2)) { 470 if (s.contains(MIDNIGHT) || s.contains(DAY_LIMIT)) { 471 return false; 472 } 473 } 474 } 475 if (dayPeriod2 == DayPeriod.midnight && !dayPeriod1.isFixed()) { 476 for (Span s : dayPeriodsToSpans.get(dayPeriod1)) { 477 if (s.contains(MIDNIGHT) || s.contains(DAY_LIMIT)) { 478 return false; 479 } 480 } 481 } 482 if (dayPeriod1 == DayPeriod.noon && !dayPeriod2.isFixed()) { 483 for (Span s : dayPeriodsToSpans.get(dayPeriod2)) { 484 if (s.contains(NOON) || s.contains(NOON - 1)) { 485 return false; 486 } 487 } 488 } 489 if (dayPeriod2 == DayPeriod.noon && !dayPeriod1.isFixed()) { 490 for (Span s : dayPeriodsToSpans.get(dayPeriod1)) { 491 if (s.contains(NOON) || s.contains(NOON - 1)) { 492 return false; 493 } 494 } 495 } 496 } 497 498 // we use the more lenient if they are mixed types 499 if (type2 == Type.format) { 500 type1 = Type.format; 501 } 502 503 // At this point, they are unequal 504 // The fixed cannot overlap among themselves 505 final boolean fixed1 = dayPeriod1.isFixed(); 506 final boolean fixed2 = dayPeriod2.isFixed(); 507 if (fixed1 && fixed2) { 508 return true; 509 } 510 // at this point, at least one is flexible. 511 // make sure the second is not flexible. 512 DayPeriod fixedOrFlexible; 513 DayPeriod flexible; 514 if (fixed1) { 515 fixedOrFlexible = dayPeriod1; 516 flexible = dayPeriod2; 517 } else { 518 fixedOrFlexible = dayPeriod2; 519 flexible = dayPeriod1; 520 } 521 522 // TODO since periods are sorted, could optimize further 523 524 switch (type1) { 525 case format: 526 { 527 if (fixedOrFlexible.span != null) { 528 if (collisionIsErrorFormat(flexible, fixedOrFlexible.span, sampleError)) { 529 return true; 530 } 531 } else { // flexible 532 for (Span s : dayPeriodsToSpans.get(fixedOrFlexible)) { 533 if (collisionIsErrorFormat(flexible, s, sampleError)) { 534 return true; 535 } 536 } 537 } 538 break; 539 } 540 case selection: 541 { 542 if (fixedOrFlexible.span != null) { 543 if (collisionIsErrorSelection( 544 flexible, fixedOrFlexible.span, sampleError)) { 545 return true; 546 } 547 } else { // flexible 548 for (Span s : dayPeriodsToSpans.get(fixedOrFlexible)) { 549 if (collisionIsErrorSelection(flexible, s, sampleError)) { 550 return true; 551 } 552 } 553 } 554 break; 555 } 556 } 557 return false; // no bad collision 558 } 559 560 // Formatting has looser collision rules, because it is always paired with a time. 561 // That is, it is not a problem if two items collide, 562 // if it doesn't cause a collision when paired with a time. 563 // But if 11:00 has the same format (eg 11 X) as 23:00, there IS a collision. 564 // So we see if there is an overlap mod 12. collisionIsErrorFormat( DayPeriod dayPeriod, Span other, Output<Integer> sampleError)565 private boolean collisionIsErrorFormat( 566 DayPeriod dayPeriod, Span other, Output<Integer> sampleError) { 567 int otherStart = other.start % NOON; 568 int otherEnd = other.getAdjustedEnd() % NOON; 569 for (Span s : dayPeriodsToSpans.get(dayPeriod)) { 570 int flexStart = s.start % NOON; 571 int flexEnd = s.getAdjustedEnd() % NOON; 572 if (otherStart <= flexEnd && otherEnd >= flexStart) { // overlap? 573 if (sampleError != null) { 574 sampleError.value = Math.max(otherStart, otherEnd); 575 } 576 return true; 577 } 578 } 579 return false; 580 } 581 582 // Selection has stricter collision rules, because is is used to select different messages. 583 // So two types with the same localization do collide unless they have exactly the same rules. collisionIsErrorSelection( DayPeriod dayPeriod, Span other, Output<Integer> sampleError)584 private boolean collisionIsErrorSelection( 585 DayPeriod dayPeriod, Span other, Output<Integer> sampleError) { 586 int otherStart = other.start; 587 int otherEnd = other.getAdjustedEnd(); 588 for (Span s : dayPeriodsToSpans.get(dayPeriod)) { 589 int flexStart = s.start; 590 int flexEnd = s.getAdjustedEnd(); 591 if (otherStart != flexStart) { // not same?? 592 if (sampleError != null) { 593 sampleError.value = (otherStart + flexStart) / 2; // half-way between 594 } 595 return true; 596 } else if (otherEnd != flexEnd) { // not same?? 597 if (sampleError != null) { 598 sampleError.value = (otherEnd + flexEnd) / 2; // half-way between 599 } 600 return true; 601 } 602 } 603 return false; 604 } 605 } 606