1 // © 2016 and later: Unicode, Inc. and others.
2 // License & terms of use: http://www.unicode.org/copyright.html#License
3 /*
4  *******************************************************************************
5  * Copyright (C) 2016, International Business Machines Corporation and
6  * others. All Rights Reserved.
7  *******************************************************************************
8  */
9 package com.ibm.icu.impl;
10 
11 import java.util.HashMap;
12 import java.util.Map;
13 
14 import com.ibm.icu.util.ICUException;
15 import com.ibm.icu.util.ULocale;
16 import com.ibm.icu.util.UResourceBundle;
17 
18 public final class DayPeriodRules {
19     public enum DayPeriod {
20         MIDNIGHT,
21         NOON,
22         MORNING1,
23         AFTERNOON1,
24         EVENING1,
25         NIGHT1,
26         MORNING2,
27         AFTERNOON2,
28         EVENING2,
29         NIGHT2,
30         AM,
31         PM;
32 
33         public static DayPeriod[] VALUES = DayPeriod.values();
34 
fromStringOrNull(CharSequence str)35         private static DayPeriod fromStringOrNull(CharSequence str) {
36             if ("midnight".contentEquals(str)) { return MIDNIGHT; }
37             if ("noon".contentEquals(str)) { return NOON; }
38             if ("morning1".contentEquals(str)) { return MORNING1; }
39             if ("afternoon1".contentEquals(str)) { return AFTERNOON1; }
40             if ("evening1".contentEquals(str)) { return EVENING1; }
41             if ("night1".contentEquals(str)) { return NIGHT1; }
42             if ("morning2".contentEquals(str)) { return MORNING2; }
43             if ("afternoon2".contentEquals(str)) { return AFTERNOON2; }
44             if ("evening2".contentEquals(str)) { return EVENING2; }
45             if ("night2".contentEquals(str)) { return NIGHT2; }
46             if ("am".contentEquals(str)) { return AM; }
47             if ("pm".contentEquals(str)) { return PM; }
48             return null;
49         }
50     }
51 
52     private enum CutoffType {
53         BEFORE,
54         AFTER,  // TODO: AFTER is deprecated in CLDR 29. Remove.
55         FROM,
56         AT;
57 
fromStringOrNull(CharSequence str)58         private static CutoffType fromStringOrNull(CharSequence str) {
59             if ("from".contentEquals(str)) { return CutoffType.FROM; }
60             if ("before".contentEquals(str)) { return CutoffType.BEFORE; }
61             if ("after".contentEquals(str)) { return CutoffType.AFTER; }
62             if ("at".contentEquals(str)) { return CutoffType.AT; }
63             return null;
64         }
65     }
66 
67     private static final class DayPeriodRulesData {
68         Map<String, Integer> localesToRuleSetNumMap = new HashMap<String, Integer>();
69         DayPeriodRules[] rules;
70         int maxRuleSetNum = -1;
71     }
72 
73     private static final class DayPeriodRulesDataSink extends UResource.Sink {
74         private DayPeriodRulesData data;
75 
DayPeriodRulesDataSink(DayPeriodRulesData data)76         private DayPeriodRulesDataSink(DayPeriodRulesData data) {
77             this.data = data;
78         }
79 
80         @Override
put(UResource.Key key, UResource.Value value, boolean noFallback)81         public void put(UResource.Key key, UResource.Value value, boolean noFallback) {
82             UResource.Table dayPeriodData = value.getTable();
83             for (int i = 0; dayPeriodData.getKeyAndValue(i, key, value); ++i) {
84                 if (key.contentEquals("locales")) {
85                     UResource.Table locales = value.getTable();
86                     for (int j = 0; locales.getKeyAndValue(j, key, value); ++j) {
87                         int setNum = parseSetNum(value.getString());
88                         data.localesToRuleSetNumMap.put(key.toString(), setNum);
89                     }
90                 } else if (key.contentEquals("rules")) {
91                     UResource.Table rules = value.getTable();
92                     processRules(rules, key, value);
93                 }
94             }
95         }
96 
processRules(UResource.Table rules, UResource.Key key, UResource.Value value)97         private void processRules(UResource.Table rules, UResource.Key key, UResource.Value value) {
98             for (int i = 0; rules.getKeyAndValue(i, key, value); ++i) {
99                 ruleSetNum = parseSetNum(key.toString());
100                 data.rules[ruleSetNum] = new DayPeriodRules();
101 
102                 UResource.Table ruleSet = value.getTable();
103                 for (int j = 0; ruleSet.getKeyAndValue(j, key, value); ++j) {
104                     period = DayPeriod.fromStringOrNull(key);
105                     if (period == null) { throw new ICUException("Unknown day period in data."); }
106 
107                     UResource.Table periodDefinition = value.getTable();
108                     for (int k = 0; periodDefinition.getKeyAndValue(k, key, value); ++k) {
109                         if (value.getType() == UResourceBundle.STRING) {
110                             // Key-value pairs (e.g. before{6:00})
111                             CutoffType type = CutoffType.fromStringOrNull(key);
112                             addCutoff(type, value.getString());
113                         } else {
114                             // Arrays (e.g. before{6:00, 24:00}
115                             cutoffType = CutoffType.fromStringOrNull(key);
116                             UResource.Array cutoffArray = value.getArray();
117                             int length = cutoffArray.getSize();
118                             for (int l = 0; l < length; ++l) {
119                                 cutoffArray.getValue(l, value);
120                                 addCutoff(cutoffType, value.getString());
121                             }
122                         }
123                     }
124                     setDayPeriodForHoursFromCutoffs();
125                     for (int k = 0; k < cutoffs.length; ++k) {
126                         cutoffs[k] = 0;
127                     }
128                 }
129                 for (DayPeriod period : data.rules[ruleSetNum].dayPeriodForHour) {
130                     if (period == null) {
131                         throw new ICUException("Rules in data don't cover all 24 hours (they should).");
132                     }
133                 }
134             }
135         }
136 
137         // Members.
138         private int cutoffs[] = new int[25];  // [0] thru [24]; 24 is allowed is "before 24".
139 
140         // "Path" to data.
141         private int ruleSetNum;
142         private DayPeriod period;
143         private CutoffType cutoffType;
144 
145         // Helpers.
addCutoff(CutoffType type, String hourStr)146         private void addCutoff(CutoffType type, String hourStr) {
147             if (type == null) { throw new ICUException("Cutoff type not recognized."); }
148             int hour = parseHour(hourStr);
149             cutoffs[hour] |= 1 << type.ordinal();
150         }
151 
setDayPeriodForHoursFromCutoffs()152         private void setDayPeriodForHoursFromCutoffs() {
153             DayPeriodRules rule = data.rules[ruleSetNum];
154             for (int startHour = 0; startHour <= 24; ++startHour) {
155                 // AT cutoffs must be either midnight or noon.
156                 if ((cutoffs[startHour] & (1 << CutoffType.AT.ordinal())) > 0) {
157                     if (startHour == 0 && period == DayPeriod.MIDNIGHT) {
158                         rule.hasMidnight = true;
159                     } else if (startHour == 12 && period == DayPeriod.NOON) {
160                         rule.hasNoon = true;
161                     } else {
162                         throw new ICUException("AT cutoff must only be set for 0:00 or 12:00.");
163                     }
164                 }
165 
166                 // FROM/AFTER and BEFORE must come in a pair.
167                 if ((cutoffs[startHour] & (1 << CutoffType.FROM.ordinal())) > 0 ||
168                         (cutoffs[startHour] & (1 << CutoffType.AFTER.ordinal())) > 0) {
169                     for (int hour = startHour + 1;; ++hour) {
170                         if (hour == startHour) {
171                             // We've gone around the array once and can't find a BEFORE.
172                             throw new ICUException(
173                                     "FROM/AFTER cutoffs must have a matching BEFORE cutoff.");
174                         }
175                         if (hour == 25) { hour = 0; }
176                         if ((cutoffs[hour] & (1 << CutoffType.BEFORE.ordinal())) > 0) {
177                             rule.add(startHour, hour, period);
178                             break;
179                         }
180                     }
181                 }
182             }
183         }
184 
parseHour(String str)185         private static int parseHour(String str) {
186             int firstColonPos = str.indexOf(':');
187             if (firstColonPos < 0 || !str.substring(firstColonPos).equals(":00")) {
188                 throw new ICUException("Cutoff time must end in \":00\".");
189             }
190 
191             String hourStr = str.substring(0, firstColonPos);
192             if (firstColonPos != 1 && firstColonPos != 2) {
193                 throw new ICUException("Cutoff time must begin with h: or hh:");
194             }
195 
196             int hour = Integer.parseInt(hourStr);
197             // parseInt() throws NumberFormatException if hourStr isn't proper.
198 
199             if (hour < 0 || hour > 24) {
200                 throw new ICUException("Cutoff hour must be between 0 and 24, inclusive.");
201             }
202 
203             return hour;
204         }
205     }  // DayPeriodRulesDataSink
206 
207     private static class DayPeriodRulesCountSink extends UResource.Sink {
208         private DayPeriodRulesData data;
209 
DayPeriodRulesCountSink(DayPeriodRulesData data)210         private DayPeriodRulesCountSink(DayPeriodRulesData data) {
211             this.data = data;
212         }
213 
214         @Override
put(UResource.Key key, UResource.Value value, boolean noFallback)215         public void put(UResource.Key key, UResource.Value value, boolean noFallback) {
216             UResource.Table rules = value.getTable();
217             for (int i = 0; rules.getKeyAndValue(i, key, value); ++i) {
218                 int setNum = parseSetNum(key.toString());
219                 if (setNum > data.maxRuleSetNum) {
220                     data.maxRuleSetNum = setNum;
221                 }
222             }
223         }
224     }
225 
226     private static final DayPeriodRulesData DATA = loadData();
227 
228     private boolean hasMidnight;
229     private boolean hasNoon;
230     private DayPeriod[] dayPeriodForHour;
231 
DayPeriodRules()232     private DayPeriodRules() {
233         hasMidnight = false;
234         hasNoon = false;
235         dayPeriodForHour = new DayPeriod[24];
236     }
237 
238     /**
239      * Get a DayPeriodRules object given a locale.
240      * If data hasn't been loaded, it will be loaded for all locales at once.
241      * @param locale locale for which the DayPeriodRules object is requested.
242      * @return a DayPeriodRules object for `locale`.
243      */
getInstance(ULocale locale)244     public static DayPeriodRules getInstance(ULocale locale) {
245         String localeCode = locale.getBaseName();
246         if (localeCode.isEmpty()) { localeCode = "root"; }
247 
248         Integer ruleSetNum = null;
249         while (ruleSetNum == null) {
250             ruleSetNum = DATA.localesToRuleSetNumMap.get(localeCode);
251             if (ruleSetNum == null) {
252                 localeCode = ULocale.getFallback(localeCode);
253                 if (localeCode.isEmpty()) {
254                     // Saves a lookup in the map.
255                     break;
256                 }
257             } else {
258                 break;
259             }
260         }
261 
262         if (ruleSetNum == null || DATA.rules[ruleSetNum] == null) {
263             // Data doesn't exist for the locale requested.
264             return null;
265         }
266 
267         return DATA.rules[ruleSetNum];
268     }
269 
getMidPointForDayPeriod(DayPeriod dayPeriod)270     public double getMidPointForDayPeriod(DayPeriod dayPeriod) {
271         int startHour = getStartHourForDayPeriod(dayPeriod);
272         int endHour = getEndHourForDayPeriod(dayPeriod);
273 
274         double midPoint = (startHour + endHour) / 2.0;
275 
276         if (startHour > endHour) {
277             // dayPeriod wraps around midnight. Shift midPoint by 12 hours, in the direction that
278             // lands it in [0, 24).
279             midPoint += 12;
280             if (midPoint >= 24) {
281                 midPoint -= 24;
282             }
283         }
284 
285         return midPoint;
286     }
287 
loadData()288     private static DayPeriodRulesData loadData() {
289         DayPeriodRulesData data = new DayPeriodRulesData();
290         ICUResourceBundle rb = ICUResourceBundle.getBundleInstance(
291                 ICUData.ICU_BASE_NAME,
292                 "dayPeriods",
293                 ICUResourceBundle.ICU_DATA_CLASS_LOADER,
294                 true);
295 
296         DayPeriodRulesCountSink countSink = new DayPeriodRulesCountSink(data);
297         rb.getAllItemsWithFallback("rules", countSink);
298 
299         data.rules = new DayPeriodRules[data.maxRuleSetNum + 1];
300         DayPeriodRulesDataSink sink = new DayPeriodRulesDataSink(data);
301         rb.getAllItemsWithFallback("", sink);
302 
303         return data;
304     }
305 
getStartHourForDayPeriod(DayPeriod dayPeriod)306     private int getStartHourForDayPeriod(DayPeriod dayPeriod) throws IllegalArgumentException {
307         if (dayPeriod == DayPeriod.MIDNIGHT) { return 0; }
308         if (dayPeriod == DayPeriod.NOON) { return 12; }
309 
310         if (dayPeriodForHour[0] == dayPeriod && dayPeriodForHour[23] == dayPeriod) {
311             // dayPeriod wraps around midnight. Start hour is later than end hour.
312             for (int i = 22; i >= 1; --i) {
313                 if (dayPeriodForHour[i] != dayPeriod) {
314                     return (i + 1);
315                 }
316             }
317         } else {
318             for (int i = 0; i <= 23; ++i) {
319                 if (dayPeriodForHour[i] == dayPeriod) {
320                     return i;
321                 }
322             }
323         }
324 
325         // dayPeriod doesn't exist in rule set; throw exception.
326         throw new IllegalArgumentException();
327     }
328 
getEndHourForDayPeriod(DayPeriod dayPeriod)329     private int getEndHourForDayPeriod(DayPeriod dayPeriod) {
330         if (dayPeriod == DayPeriod.MIDNIGHT) { return 0; }
331         if (dayPeriod == DayPeriod.NOON) { return 12; }
332 
333         if (dayPeriodForHour[0] == dayPeriod && dayPeriodForHour[23] == dayPeriod) {
334             // dayPeriod wraps around midnight. End hour is before start hour.
335             for (int i = 1; i <= 22; ++i) {
336                 if (dayPeriodForHour[i] != dayPeriod) {
337                     // i o'clock is when a new period starts, therefore when the old period ends.
338                     return i;
339                 }
340             }
341         } else {
342             for (int i = 23; i >= 0; --i) {
343                 if (dayPeriodForHour[i] == dayPeriod) {
344                     return (i + 1);
345                 }
346             }
347         }
348 
349         // dayPeriod doesn't exist in rule set; throw exception.
350         throw new IllegalArgumentException();
351     }
352 
353     // Getters.
hasMidnight()354     public boolean hasMidnight() { return hasMidnight; }
hasNoon()355     public boolean hasNoon() { return hasNoon; }
getDayPeriodForHour(int hour)356     public DayPeriod getDayPeriodForHour(int hour) { return dayPeriodForHour[hour]; }
357 
358     // Helpers.
add(int startHour, int limitHour, DayPeriod period)359     private void add(int startHour, int limitHour, DayPeriod period) {
360         for (int i = startHour; i != limitHour; ++i) {
361             if (i == 24) { i = 0; }
362             dayPeriodForHour[i] = period;
363         }
364     }
365 
parseSetNum(String setNumStr)366     private static int parseSetNum(String setNumStr) {
367         if (!setNumStr.startsWith("set")) {
368             throw new ICUException("Set number should start with \"set\".");
369         }
370 
371         String numStr = setNumStr.substring(3);  // e.g. "set17" -> "17"
372         return Integer.parseInt(numStr);  // This throws NumberFormatException if numStr isn't a proper number.
373     }
374 }
375