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 29 private Type(String _pathValue) { 30 pathValue = _pathValue; 31 } 32 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 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 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 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 */ 73 public int getAdjustedEnd() { 74 return includesEnd ? end : end - 1; 75 } 76 77 @Override 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 85 public int hashCode() { 86 return start * 37 + end; 87 } 88 89 @Override 90 public String toString() { 91 return dayPeriod + ":" + toStringPlain(); 92 } 93 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 107 private DayPeriod(int start, int end) { 108 span = new Span(start, end, this); 109 } 110 111 private DayPeriod() { 112 span = null; 113 } 114 115 public static DayPeriod fromString(String value) { 116 return valueOf(value); 117 } 118 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 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 149 public DayPeriodInfo finish(String[] locales) { 150 DayPeriodInfo result = new DayPeriodInfo(info, locales); 151 info.clear(); 152 return result; 153 } 154 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 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 */ 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 */ 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 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 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 */ 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 */ 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 */ 306 public Row.R3<Integer, Boolean, DayPeriod> getPeriod(int index) { 307 return Row.of(getSpan(index).start, true, getSpan(index).dayPeriod); 308 } 309 310 public Span getSpan(int index) { 311 return spans[index]; 312 } 313 314 public List<DayPeriod> getPeriods() { 315 return Arrays.asList(dayPeriods); 316 } 317 318 @Override 319 public String toString() { 320 return dayPeriodsToSpans.values().toString(); 321 } 322 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 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 { 356 allowableCollisions.put(DayPeriod.am, EnumSet.of(DayPeriod.morning1, DayPeriod.morning2)); 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 */ 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. 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. 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 }