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