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 return Row.of(span.start, span.end, true); 245 } 246 getFirstDayPeriodSpan(DayPeriodInfo.DayPeriod dayPeriod)247 public Span getFirstDayPeriodSpan(DayPeriodInfo.DayPeriod dayPeriod) { 248 switch (dayPeriod) { 249 case am: 250 return DayPeriod.am.span; 251 case pm: 252 return DayPeriod.pm.span; 253 default: 254 Set<Span> spanList = dayPeriodsToSpans.get(dayPeriod); 255 return spanList == null ? null : dayPeriodsToSpans.get(dayPeriod).iterator().next(); 256 } 257 } 258 getDayPeriodSpans(DayPeriodInfo.DayPeriod dayPeriod)259 public Set<Span> getDayPeriodSpans(DayPeriodInfo.DayPeriod dayPeriod) { 260 switch (dayPeriod) { 261 case am: 262 return Collections.singleton(DayPeriod.am.span); 263 case pm: 264 return Collections.singleton(DayPeriod.pm.span); 265 default: 266 return dayPeriodsToSpans.get(dayPeriod); 267 } 268 } 269 270 /** 271 * Returns the day period for the time. 272 * 273 * @param millisInDay 274 * If not (millisInDay > 0 && The millisInDay < DAY_LIMIT) throws exception. 275 * @return corresponding day period 276 */ getDayPeriod(int millisInDay)277 public DayPeriodInfo.DayPeriod getDayPeriod(int millisInDay) { 278 if (millisInDay < MIDNIGHT) { 279 throw new IllegalArgumentException("millisInDay too small"); 280 } else if (millisInDay >= DAY_LIMIT) { 281 throw new IllegalArgumentException("millisInDay too big"); 282 } 283 for (int i = 0; i < spans.length; ++i) { 284 if (spans[i].contains(millisInDay)) { 285 return spans[i].dayPeriod; 286 } 287 } 288 throw new IllegalArgumentException("internal error"); 289 } 290 291 /** 292 * Returns the number of periods in the day 293 * 294 * @return 295 */ getPeriodCount()296 public int getPeriodCount() { 297 return spans.length; 298 } 299 300 /** 301 * For the nth period in the day, returns the start, whether the start is included, and the period ID. 302 * 303 * @param index 304 * @return data 305 */ getPeriod(int index)306 public Row.R3<Integer, Boolean, DayPeriod> getPeriod(int index) { 307 return Row.of(getSpan(index).start, true, getSpan(index).dayPeriod); 308 } 309 getSpan(int index)310 public Span getSpan(int index) { 311 return spans[index]; 312 } 313 getPeriods()314 public List<DayPeriod> getPeriods() { 315 return Arrays.asList(dayPeriods); 316 } 317 318 @Override toString()319 public String toString() { 320 return dayPeriodsToSpans.values().toString(); 321 } 322 toString(DayPeriod dayPeriod)323 public String toString(DayPeriod dayPeriod) { 324 switch (dayPeriod) { 325 case midnight: 326 return "00:00"; 327 case noon: 328 return "12:00"; 329 case am: 330 return "00:00 – 12:00⁻"; 331 case pm: 332 return "12:00 – 24:00⁻"; 333 default: 334 break; 335 } 336 StringBuilder result = new StringBuilder(); 337 for (Span span : dayPeriodsToSpans.get(dayPeriod)) { 338 if (result.length() != 0) { 339 result.append("; "); 340 } 341 result.append(span.toStringPlain()); 342 } 343 return result.toString(); 344 } 345 formatTime(int time)346 public static String formatTime(int time) { 347 int minutes = time / (60 * 1000); 348 int hours = minutes / 60; 349 minutes -= hours * 60; 350 return String.format("%02d:%02d", hours, minutes); 351 } 352 353 // Day periods that are allowed to collide 354 private static final EnumMap<DayPeriod, EnumSet<DayPeriod>> allowableCollisions = new EnumMap<DayPeriod, EnumSet<DayPeriod>>(DayPeriod.class); 355 static { allowableCollisions.put(DayPeriod.am, EnumSet.of(DayPeriod.morning1, DayPeriod.morning2))356 allowableCollisions.put(DayPeriod.am, EnumSet.of(DayPeriod.morning1, DayPeriod.morning2)); allowableCollisions.put(DayPeriod.pm, EnumSet.of(DayPeriod.afternoon1, DayPeriod.afternoon2, DayPeriod.evening1, DayPeriod.evening2))357 allowableCollisions.put(DayPeriod.pm, EnumSet.of(DayPeriod.afternoon1, DayPeriod.afternoon2, DayPeriod.evening1, DayPeriod.evening2)); 358 } 359 360 /** 361 * Test if there is a problem with dayPeriod1 and dayPeriod2 having the same localization. 362 * @param type1 363 * @param dayPeriod1 364 * @param type2 TODO 365 * @param dayPeriod2 366 * @param sampleError TODO 367 * @return 368 */ collisionIsError(DayPeriodInfo.Type type1, DayPeriod dayPeriod1, Type type2, DayPeriod dayPeriod2, Output<Integer> sampleError)369 public boolean collisionIsError(DayPeriodInfo.Type type1, DayPeriod dayPeriod1, Type type2, DayPeriod dayPeriod2, 370 Output<Integer> sampleError) { 371 if (dayPeriod1 == dayPeriod2) { 372 return false; 373 } 374 if ((allowableCollisions.containsKey(dayPeriod1) && allowableCollisions.get(dayPeriod1).contains(dayPeriod2)) || 375 (allowableCollisions.containsKey(dayPeriod2) && allowableCollisions.get(dayPeriod2).contains(dayPeriod1))) { 376 return false; 377 } 378 379 // we use the more lenient if they are mixed types 380 if (type2 == Type.format) { 381 type1 = Type.format; 382 } 383 384 // At this point, they are unequal 385 // The fixed cannot overlap among themselves 386 final boolean fixed1 = dayPeriod1.isFixed(); 387 final boolean fixed2 = dayPeriod2.isFixed(); 388 if (fixed1 && fixed2) { 389 return true; 390 } 391 // at this point, at least one is flexible. 392 // make sure the second is not flexible. 393 DayPeriod fixedOrFlexible; 394 DayPeriod flexible; 395 if (fixed1) { 396 fixedOrFlexible = dayPeriod1; 397 flexible = dayPeriod2; 398 } else { 399 fixedOrFlexible = dayPeriod2; 400 flexible = dayPeriod1; 401 } 402 403 // TODO since periods are sorted, could optimize further 404 405 switch (type1) { 406 case format: { 407 if (fixedOrFlexible.span != null) { 408 if (collisionIsErrorFormat(flexible, fixedOrFlexible.span, sampleError)) { 409 return true; 410 } 411 } else { // flexible 412 for (Span s : dayPeriodsToSpans.get(fixedOrFlexible)) { 413 if (collisionIsErrorFormat(flexible, s, sampleError)) { 414 return true; 415 } 416 } 417 } 418 break; 419 } 420 case selection: { 421 if (fixedOrFlexible.span != null) { 422 if (collisionIsErrorSelection(flexible, fixedOrFlexible.span, sampleError)) { 423 return true; 424 } 425 } else { // flexible 426 for (Span s : dayPeriodsToSpans.get(fixedOrFlexible)) { 427 if (collisionIsErrorSelection(flexible, s, sampleError)) { 428 return true; 429 } 430 } 431 } 432 break; 433 } 434 } 435 return false; // no bad collision 436 } 437 438 // Formatting has looser collision rules, because it is always paired with a time. 439 // That is, it is not a problem if two items collide, 440 // if it doesn't cause a collision when paired with a time. 441 // But if 11:00 has the same format (eg 11 X) as 23:00, there IS a collision. 442 // So we see if there is an overlap mod 12. collisionIsErrorFormat(DayPeriod dayPeriod, Span other, Output<Integer> sampleError)443 private boolean collisionIsErrorFormat(DayPeriod dayPeriod, Span other, Output<Integer> sampleError) { 444 int otherStart = other.start % NOON; 445 int otherEnd = other.getAdjustedEnd() % NOON; 446 for (Span s : dayPeriodsToSpans.get(dayPeriod)) { 447 int flexStart = s.start % NOON; 448 int flexEnd = s.getAdjustedEnd() % NOON; 449 if (otherStart <= flexEnd && otherEnd >= flexStart) { // overlap? 450 if (sampleError != null) { 451 sampleError.value = Math.max(otherStart, otherEnd); 452 } 453 return true; 454 } 455 } 456 return false; 457 } 458 459 // Selection has stricter collision rules, because is is used to select different messages. 460 // So two types with the same localization do collide unless they have exactly the same rules. collisionIsErrorSelection(DayPeriod dayPeriod, Span other, Output<Integer> sampleError)461 private boolean collisionIsErrorSelection(DayPeriod dayPeriod, Span other, Output<Integer> sampleError) { 462 int otherStart = other.start; 463 int otherEnd = other.getAdjustedEnd(); 464 for (Span s : dayPeriodsToSpans.get(dayPeriod)) { 465 int flexStart = s.start; 466 int flexEnd = s.getAdjustedEnd(); 467 if (otherStart != flexStart) { // not same?? 468 if (sampleError != null) { 469 sampleError.value = (otherStart + flexStart) / 2; // half-way between 470 } 471 return true; 472 } else if (otherEnd != flexEnd) { // not same?? 473 if (sampleError != null) { 474 sampleError.value = (otherEnd + flexEnd) / 2; // half-way between 475 } 476 return true; 477 } 478 } 479 return false; 480 } 481 }