View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.javascript.host.intl;
16  
17  import java.time.ZoneId;
18  import java.time.chrono.Chronology;
19  import java.time.chrono.JapaneseChronology;
20  import java.time.chrono.ThaiBuddhistChronology;
21  import java.time.format.DateTimeFormatter;
22  import java.time.format.DecimalStyle;
23  import java.time.temporal.TemporalAccessor;
24  import java.util.Date;
25  import java.util.HashMap;
26  import java.util.Map;
27  import java.util.concurrent.ConcurrentHashMap;
28  
29  import org.htmlunit.BrowserVersion;
30  import org.htmlunit.corejs.javascript.Context;
31  import org.htmlunit.corejs.javascript.Function;
32  import org.htmlunit.corejs.javascript.FunctionObject;
33  import org.htmlunit.corejs.javascript.NativeArray;
34  import org.htmlunit.corejs.javascript.Scriptable;
35  import org.htmlunit.corejs.javascript.VarScope;
36  import org.htmlunit.javascript.HtmlUnitScriptable;
37  import org.htmlunit.javascript.JavaScriptEngine;
38  import org.htmlunit.javascript.configuration.JsxClass;
39  import org.htmlunit.javascript.configuration.JsxConstructor;
40  import org.htmlunit.javascript.configuration.JsxFunction;
41  import org.htmlunit.javascript.configuration.JsxStaticFunction;
42  import org.htmlunit.javascript.configuration.JsxSymbolConstant;
43  import org.htmlunit.javascript.host.Window;
44  import org.htmlunit.util.StringUtils;
45  
46  /**
47   * A JavaScript object for Intl.DateTimeFormat.
48   *
49   * @author Ahmed Ashour
50   * @author Ronald Brill
51   * @author Lai Quang Duong
52   *
53   * @see <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat">MDN Documentation</a>
54   */
55  @JsxClass
56  public class DateTimeFormat extends HtmlUnitScriptable {
57  
58      /** Symbol.toStringTag support. */
59      @JsxSymbolConstant
60      public static final String TO_STRING_TAG = "Intl.DateTimeFormat";
61  
62      private static final ConcurrentHashMap<String, String> CHROME_FORMATS_ = new ConcurrentHashMap<>();
63      private static final ConcurrentHashMap<String, String> EDGE_FORMATS_ = new ConcurrentHashMap<>();
64      private static final ConcurrentHashMap<String, String> FF_FORMATS_ = new ConcurrentHashMap<>();
65      private static final ConcurrentHashMap<String, String> FF_ESR_FORMATS_ = new ConcurrentHashMap<>();
66  
67      private static final ConcurrentHashMap<String, Chronology> CHROME_CHRONOLOGIES_ = new ConcurrentHashMap<>();
68      private static final ConcurrentHashMap<String, Chronology> EDGE_CHRONOLOGIES_ = new ConcurrentHashMap<>();
69      private static final ConcurrentHashMap<String, Chronology> FF_CHRONOLOGIES_ = new ConcurrentHashMap<>();
70      private static final ConcurrentHashMap<String, Chronology> FF_ESR_CHRONOLOGIES_ = new ConcurrentHashMap<>();
71  
72      private transient DateTimeFormatHelper formatter_;
73  
74      static {
75          final String ddSlash = "\u200Edd\u200E/\u200EMM\u200E/\u200EYYYY";
76          final String ddDash = "\u200Edd\u200E-\u200EMM\u200E-\u200EYYYY";
77          final String ddDot = "\u200Edd\u200E.\u200EMM\u200E.\u200EYYYY";
78          final String ddDotDot = "\u200Edd\u200E.\u200EMM\u200E.\u200EYYYY\u200E.";
79          final String ddDotBlank = "\u200Edd\u200E. \u200EMM\u200E. \u200EYYYY";
80          final String ddDotBlankDot = "\u200Edd\u200E. \u200EMM\u200E. \u200EYYYY.";
81          final String mmSlash = "\u200EMM\u200E/\u200Edd\u200E/\u200EYYYY";
82          final String yyyySlash = "\u200EYYYY\u200E/\u200EMM\u200E/\u200Edd";
83          final String yyyyDash = "\u200EYYYY\u200E-\u200EMM\u200E-\u200Edd";
84          final String yyyyDotBlankDot = "\u200EYYYY\u200E. \u200EMM\u200E. \u200Edd.";
85  
86          final Map<String, String> commonFormats = new HashMap<>();
87          commonFormats.put("", ddDot);
88          commonFormats.put("ar", "dd\u200F/MM\u200F/YYYY");
89          commonFormats.put("ban", mmSlash);
90          commonFormats.put("be", ddDot);
91          commonFormats.put("bg", ddDot + "\u200E \u0433.");
92          commonFormats.put("ca", ddSlash);
93          commonFormats.put("cs", ddDotBlank);
94          commonFormats.put("da", ddDot);
95          commonFormats.put("de", ddDot);
96          commonFormats.put("el", ddSlash);
97          commonFormats.put("en", mmSlash);
98          commonFormats.put("en-CA", yyyyDash);
99          commonFormats.put("en-NZ", ddSlash);
100         commonFormats.put("en-PA", ddSlash);
101         commonFormats.put("en-PR", ddSlash);
102         commonFormats.put("en-PH", mmSlash);
103         commonFormats.put("en-AU", ddSlash);
104         commonFormats.put("en-GB", ddSlash);
105         commonFormats.put("en-IE", ddSlash);
106         commonFormats.put("en-IN", ddSlash);
107         commonFormats.put("en-MT", ddSlash);
108         commonFormats.put("en-SG", ddSlash);
109         commonFormats.put("en-ZA", yyyySlash);
110         commonFormats.put("es", ddSlash);
111         commonFormats.put("es-CL", ddDash);
112         commonFormats.put("es-PA", mmSlash);
113         commonFormats.put("es-PR", mmSlash);
114         commonFormats.put("es-US", ddSlash);
115         commonFormats.put("et", ddDot);
116         commonFormats.put("fi", ddDot);
117         commonFormats.put("fr", ddSlash);
118         commonFormats.put("fr-CA", yyyyDash);
119         commonFormats.put("ga", ddSlash);
120         commonFormats.put("hi", ddSlash);
121         commonFormats.put("hr", ddDotBlankDot);
122         commonFormats.put("hu", yyyyDotBlankDot);
123         commonFormats.put("id", ddSlash);
124         commonFormats.put("in", ddSlash);
125         commonFormats.put("is", ddDot);
126         commonFormats.put("it", ddSlash);
127         commonFormats.put("iw", ddDot);
128         commonFormats.put("ja", yyyySlash);
129         commonFormats.put("ja-JP-u-ca-japanese", "'H'yy/MM/dd");
130         commonFormats.put("ko", yyyyDotBlankDot);
131         commonFormats.put("lt", yyyyDash);
132         commonFormats.put("lv", ddDotDot);
133         commonFormats.put("mk", ddDot + "\u200E \u0433.");
134         commonFormats.put("ms", ddSlash);
135         commonFormats.put("mt", mmSlash);
136         commonFormats.put("nl", ddDash);
137         commonFormats.put("nl-BE", ddSlash);
138         commonFormats.put("pl", ddDot);
139         commonFormats.put("pt", ddSlash);
140         commonFormats.put("ro", ddDot);
141         commonFormats.put("ru", ddDot);
142         commonFormats.put("sk", ddDotBlank);
143         commonFormats.put("sl", ddDotBlank);
144         commonFormats.put("sq", ddDot);
145         commonFormats.put("sr", ddDotBlankDot);
146         commonFormats.put("sv", yyyyDash);
147         commonFormats.put("th", ddSlash);
148         commonFormats.put("tr", ddDot);
149         commonFormats.put("uk", ddDot);
150         commonFormats.put("vi", ddSlash);
151         commonFormats.put("zh", yyyySlash);
152         commonFormats.put("zh-HK", ddSlash);
153         commonFormats.put("zh-SG", "\u200EYYYY\u200E\u5E74\u200EMM\u200E\u6708\u200Edd\u200E\u65E5");
154         commonFormats.put("fr-CH", ddDot);
155 
156         FF_FORMATS_.putAll(commonFormats);
157         FF_ESR_FORMATS_.putAll(commonFormats);
158 
159         commonFormats.put("be", mmSlash);
160         commonFormats.put("ga", mmSlash);
161         commonFormats.put("is", mmSlash);
162         commonFormats.put("mk", mmSlash);
163 
164         EDGE_FORMATS_.putAll(commonFormats);
165 
166         CHROME_FORMATS_.putAll(commonFormats);
167         CHROME_FORMATS_.put("sq", mmSlash);
168 
169         final Map<String, Chronology> commonChronologies = new HashMap<>();
170         commonChronologies.put("ja-JP-u-ca-japanese", JapaneseChronology.INSTANCE);
171         commonChronologies.put("th", ThaiBuddhistChronology.INSTANCE);
172         commonChronologies.put("th-TH", ThaiBuddhistChronology.INSTANCE);
173 
174         FF_CHRONOLOGIES_.putAll(commonChronologies);
175         FF_ESR_CHRONOLOGIES_.putAll(commonChronologies);
176         CHROME_CHRONOLOGIES_.putAll(commonChronologies);
177         EDGE_CHRONOLOGIES_.putAll(commonChronologies);
178     }
179 
180     /**
181      * Default constructor.
182      */
183     public DateTimeFormat() {
184         super();
185     }
186 
187     private DateTimeFormat(final String[] locales, final BrowserVersion browserVersion) {
188         super();
189 
190         final Map<String, String> formats;
191         final Map<String, Chronology> chronologies;
192         if (browserVersion.isChrome()) {
193             formats = CHROME_FORMATS_;
194             chronologies = CHROME_CHRONOLOGIES_;
195         }
196         else if (browserVersion.isEdge()) {
197             formats = EDGE_FORMATS_;
198             chronologies = EDGE_CHRONOLOGIES_;
199         }
200         else if (browserVersion.isFirefoxESR()) {
201             formats = FF_ESR_FORMATS_;
202             chronologies = FF_ESR_CHRONOLOGIES_;
203         }
204         else {
205             formats = FF_FORMATS_;
206             chronologies = FF_CHRONOLOGIES_;
207         }
208 
209         String locale = browserVersion.getBrowserLocale().toLanguageTag();
210         String pattern = getPattern(formats, locale);
211 
212         for (final String l : locales) {
213             pattern = getPattern(formats, l);
214             if (pattern != null) {
215                 locale = l;
216             }
217         }
218 
219         if (pattern == null) {
220             pattern = formats.get("");
221         }
222 
223         if (!locale.startsWith("ar")) {
224             pattern = pattern.replace("\u200E", "");
225         }
226 
227         final Chronology chronology = getChronology(chronologies, locale);
228 
229         formatter_ = new DateTimeFormatHelper(locale, chronology, pattern);
230     }
231 
232     private static String getPattern(final Map<String, String> formats, String locale) {
233         if ("no-NO-NY".equals(locale)) {
234             throw JavaScriptEngine.rangeError("Invalid language tag: " + locale);
235         }
236         String pattern = formats.get(locale);
237         while (pattern == null && locale.indexOf('-') != -1) {
238             locale = locale.substring(0, locale.lastIndexOf('-'));
239             pattern = formats.get(locale);
240         }
241         return pattern;
242     }
243 
244     private static Chronology getChronology(final Map<String, Chronology> chronologies, String locale) {
245         Chronology chronology = chronologies.get(locale);
246         while (chronology == null && locale.indexOf('-') != -1) {
247             locale = locale.substring(0, locale.lastIndexOf('-'));
248             chronology = chronologies.get(locale);
249         }
250         return chronology;
251     }
252 
253     /**
254      * JavaScript constructor.
255      * @param cx the current context
256      * @param scope the scope
257      * @param args the arguments to the DateTimeFormat constructor
258      * @param ctorObj the function object
259      * @param inNewExpr {@code true} if invoked with the {@code new} operator
260      * @return the Java object that JavaScript can access
261      */
262     @JsxConstructor
263     public static Scriptable jsConstructor(final Context cx, final VarScope scope,
264             final Object[] args, final Function ctorObj, final boolean inNewExpr) {
265         final String[] locales;
266         if (args.length != 0) {
267             if (args[0] instanceof NativeArray array) {
268                 locales = new String[(int) array.getLength()];
269                 for (int i = 0; i < locales.length; i++) {
270                     locales[i] = JavaScriptEngine.toString(array.get(i));
271                 }
272             }
273             else {
274                 locales = new String[] {JavaScriptEngine.toString(args[0])};
275             }
276         }
277         else {
278             locales = new String[0];
279         }
280 
281         final Window window = getWindow(ctorObj);
282         final DateTimeFormat format = new DateTimeFormat(locales, window.getBrowserVersion());
283         format.setParentScope(getTopLevelScope(scope));
284         format.setPrototype(((FunctionObject) ctorObj).getClassPrototype());
285         return format;
286     }
287 
288     /**
289      * Formats a date according to the locale and formatting options of this {@code DateTimeFormat} object.
290      * @param object the JavaScript object to convert
291      * @return the formatted date
292      */
293     @JsxFunction
294     public String format(final Object object) {
295         final Date date = (Date) Context.jsToJava(object, Date.class);
296         return formatter_.format(date, Context.getCurrentContext().getTimeZone().toZoneId());
297     }
298 
299     /**
300      * Returns the resolved options for this Intl.DateTimeFormat object.
301      * @return a new object with properties reflecting the locale and date and time formatting options
302      *         computed during the initialization of the given {@code DateTimeFormat} object.
303      */
304     @JsxFunction
305     public Scriptable resolvedOptions() {
306         final Context cx = Context.getCurrentContext();
307         final Scriptable options = JavaScriptEngine.newObject(getParentScope());
308         options.put("timeZone", options, cx.getTimeZone().getID());
309 
310         if (StringUtils.isEmptyOrNull(formatter_.locale_)) {
311             options.put("locale", options, cx.getLocale().toLanguageTag());
312         }
313         else {
314             options.put("locale", options, formatter_.locale_);
315         }
316         return options;
317     }
318 
319     /**
320      * Returns an array containing those of the provided locales that are supported
321      * without having to fall back to the default locale.
322      * @param localesArgument A string with a BCP 47 language tag, or an array of such strings
323      * @param options unused
324      * @return an array containing supported locales
325      */
326     @JsxStaticFunction
327     public static Scriptable supportedLocalesOf(final Scriptable localesArgument, final Scriptable options) {
328         return Intl.supportedLocalesOf(localesArgument);
329     }
330 
331     @Override
332     public Object getDefaultValue(final Class<?> hint) {
333         if (String.class.equals(hint) || hint == null) {
334             return "[object Intl.DateTimeFormat]";
335         }
336         return super.getDefaultValue(hint);
337     }
338 
339     /**
340      * Helper.
341      */
342     static final class DateTimeFormatHelper {
343 
344         private final DateTimeFormatter formatter_;
345         private final Chronology chronology_;
346         private final String locale_;
347 
348         DateTimeFormatHelper(final String locale, final Chronology chronology, final String pattern) {
349             locale_ = locale;
350             chronology_ = chronology;
351 
352             if (locale.startsWith("ar")
353                     && !"ar-DZ".equals(locale)
354                     && !"ar-LY".equals(locale)
355                     && !"ar-MA".equals(locale)
356                     && !"ar-TN".equals(locale)) {
357                 final DecimalStyle decimalStyle = DecimalStyle.STANDARD.withZeroDigit('\u0660');
358                 formatter_ = DateTimeFormatter.ofPattern(pattern).withDecimalStyle(decimalStyle);
359             }
360             else {
361                 formatter_ = DateTimeFormatter.ofPattern(pattern);
362             }
363         }
364 
365         /**
366          * Formats a date according to the locale and formatting options of this {@code DateTimeFormat} object.
367          * @param date the JavaScript object to convert
368          * @param zoneId the current time zone id
369          * @return the formatted date
370          */
371         String format(final Date date, final ZoneId zoneId) {
372             TemporalAccessor zonedDateTime = date.toInstant().atZone(zoneId);
373             if (chronology_ != null) {
374                 zonedDateTime = chronology_.date(zonedDateTime);
375             }
376             return formatter_.format(zonedDateTime);
377         }
378     }
379 }