summaryrefslogtreecommitdiffstats
path: root/comm/calendar/base/modules/utils/calDateTimeFormatter.jsm
blob: 42df519e22a36f2ab2292eff892d8b3db5cbe454 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");

const lazy = {};

ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");

XPCOMUtils.defineLazyGetter(lazy, "gDateStringBundle", () =>
  Services.strings.createBundle("chrome://calendar/locale/dateFormat.properties")
);

XPCOMUtils.defineLazyPreferenceGetter(lazy, "dateFormat", "calendar.date.format", 0);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "timeBeforeDate",
  "calendar.date.formatTimeBeforeDate",
  false
);

/** Cache of calls to new Services.intl.DateTimeFormat. */
var formatCache = new Map();

/*
 * Date time formatting functions for display.
 */

// NOTE: This module should not be loaded directly, it is available when
// including calUtils.jsm under the cal.dtz.formatter namespace.

const EXPORTED_SYMBOLS = ["formatter"];

var formatter = {
  /**
   * Format a date in either short or long format, depending on the users preference.
   *
   * @param {calIDateTime} aDate - The datetime to format.
   * @returns {string} A string representing the date part of the datetime.
   */
  formatDate(aDate) {
    // Format the date using user's format preference (long or short)
    return lazy.dateFormat == 0 ? this.formatDateLong(aDate) : this.formatDateShort(aDate);
  },

  /**
   * Format a date into a short format, for example "12/17/2005".
   *
   * @param {calIDateTime} aDate - The datetime to format.
   * @returns {string} A string representing the date part of the datetime.
   */
  formatDateShort(aDate) {
    return formatDateTimeWithOptions(aDate, { dateStyle: "short" });
  },

  /**
   * Format a date into a long format, for example "Sat Dec 17 2005".
   *
   * @param {calIDateTime} aDate - The datetime to format.
   * @returns {string} A string representing the date part of the datetime.
   */
  formatDateLong(aDate) {
    return formatDateTimeWithOptions(aDate, { dateStyle: "full" });
  },

  /**
   * Format a date into a short format without mentioning the year, for example "Dec 17"
   *
   * @param {calIDateTime} aDate - The datetime to format.
   * @returns {string} A string representing the date part of the datetime.
   */
  formatDateWithoutYear(aDate) {
    return formatDateTimeWithOptions(aDate, { month: "short", day: "numeric" });
  },

  /**
   * Format a date into a long format without mentioning the year, for example
   * "Monday, December 17".
   *
   * @param {calIDateTime} aDate - The datetime to format.
   * @returns {string} A string representing the date part of the datetime.
   */
  formatDateLongWithoutYear(aDate) {
    return formatDateTimeWithOptions(aDate, { weekday: "long", month: "long", day: "numeric" });
  },

  /**
   * Format the time portion of a date-time object. Note: only the hour and
   * minutes are shown.
   *
   * @param {calIDateTime} time - The date-time to format the time of.
   * @param {boolean} [preferEndOfDay = false] - Whether to prefer showing a
   *   midnight time as the end of a day, rather than the start of the day, if
   *   the time formatting allows for it. I.e. if the formatter would use a
   *   24-hour format, then this would show midnight as 24:00, rather than
   *   00:00.
   *
   * @returns {string} A string representing the time.
   */
  formatTime(time, preferEndOfDay = false) {
    if (time.isDate) {
      return lazy.gDateStringBundle.GetStringFromName("AllDay");
    }

    const options = { timeStyle: "short" };
    if (preferEndOfDay && time.hour == 0 && time.minute == 0) {
      // Midnight. Note that the timeStyle is short, so we don't test for
      // seconds.
      // Test what hourCycle the default formatter would use.
      if (getFormatter(options).resolvedOptions().hourCycle == "h23") {
        // Midnight start-of-day is 00:00, so we can show midnight end-of-day
        // as 24:00.
        options.hourCycle = "h24";
      }
      // NOTE: Regarding the other hourCycle values:
      // + "h24": This is not expected in any locale.
      // + "h12": In a 12-hour format that cycles 12 -> 1 -> ... -> 11, there is
      //   no convention to distinguish between midnight start-of-day and
      //   midnight end-of-day. So we do nothing.
      // + "h11": The ja-JP locale with a 12-hour format returns this. In this
      //   locale, midnight start-of-day is shown as "午前0:00" (i.e. 0 AM),
      //   which means midnight end-of-day can be shown as "午後12:00" (12 PM).
      //   However, Intl.DateTimeFormatter does not expose a means to do this.
      //   Just forcing a h12 hourCycle will show midnight as "午前12:00", which
      //   would be incorrect in this locale. Therefore, we similarly do nothing
      //   in this case as well.
    }

    return formatDateTimeWithOptions(time, options);
  },

  /**
   * Format a datetime into the format specified by the OS settings. Will omit the seconds from the
   * output.
   *
   * @param {calIDateTime} aDate - The datetime to format.
   * @returns {string} A string representing the datetime.
   */
  formatDateTime(aDate) {
    let formattedDate = this.formatDate(aDate);
    let formattedTime = this.formatTime(aDate);

    if (lazy.timeBeforeDate) {
      return formattedTime + " " + formattedDate;
    }
    return formattedDate + " " + formattedTime;
  },

  /**
   * Format a time interval like formatInterval, but show only the time.
   *
   * @param {calIDateTime} aStartDate - The start of the interval.
   * @param {calIDateTime} aEndDate - The end of the interval.
   * @returns {string} The formatted time interval.
   */
  formatTimeInterval(aStartDate, aEndDate) {
    if (!aStartDate && aEndDate) {
      return this.formatTime(aEndDate);
    }
    if (!aEndDate && aStartDate) {
      return this.formatTime(aStartDate);
    }
    if (!aStartDate && !aEndDate) {
      return "";
    }

    // TODO do we need l10n for this?
    // TODO should we check for the same day? The caller should know what
    // he is doing...
    return this.formatTime(aStartDate) + "\u2013" + this.formatTime(aEndDate);
  },

  /**
   * Format a date/time interval to a string. The returned string may assume
   * that the dates are so close to each other, that it can leave out some parts
   * of the part string denoting the end date.
   *
   * @param {calIDateTime} startDate - The start of the interval.
   * @param {calIDateTime} endDate - The end of the interval.
   * @returns {string} - A string describing the interval in a legible form.
   */
  formatInterval(startDate, endDate) {
    let format = this.formatIntervalParts(startDate, endDate);
    switch (format.type) {
      case "task-without-dates":
        return lazy.cal.l10n.getCalString("datetimeIntervalTaskWithoutDate");

      case "task-without-due-date":
        return lazy.cal.l10n.getCalString("datetimeIntervalTaskWithoutDueDate", [
          format.startDate,
          format.startTime,
        ]);

      case "task-without-start-date":
        return lazy.cal.l10n.getCalString("datetimeIntervalTaskWithoutStartDate", [
          format.endDate,
          format.endTime,
        ]);

      case "all-day":
        return format.startDate;

      case "all-day-between-years":
        return lazy.cal.l10n.getCalString("daysIntervalBetweenYears", [
          format.startMonth,
          format.startDay,
          format.startYear,
          format.endMonth,
          format.endDay,
          format.endYear,
        ]);

      case "all-day-in-month":
        return lazy.cal.l10n.getCalString("daysIntervalInMonth", [
          format.month,
          format.startDay,
          format.endDay,
          format.year,
        ]);

      case "all-day-between-months":
        return lazy.cal.l10n.getCalString("daysIntervalBetweenMonths", [
          format.startMonth,
          format.startDay,
          format.endMonth,
          format.endDay,
          format.year,
        ]);

      case "same-date-time":
        return lazy.cal.l10n.getCalString("datetimeIntervalOnSameDateTime", [
          format.startDate,
          format.startTime,
        ]);

      case "same-day":
        return lazy.cal.l10n.getCalString("datetimeIntervalOnSameDay", [
          format.startDate,
          format.startTime,
          format.endTime,
        ]);

      case "several-days":
        return lazy.cal.l10n.getCalString("datetimeIntervalOnSeveralDays", [
          format.startDate,
          format.startTime,
          format.endDate,
          format.endTime,
        ]);
      default:
        return "";
    }
  },

  /**
   * Object used to describe the parts of a formatted interval.
   *
   * @typedef {object} IntervalParts
   * @property {string} type
   *   Used to distinguish IntervalPart results.
   * @property {string?} startDate
   *   The full date of the start of the interval.
   * @property {string?} startTime
   *   The time part of the start of the interval.
   * @property {string?} startDay
   *   The day (of the month) the interval starts on.
   * @property {string?} startMonth
   *   The month the interval starts on.
   * @property {string?} startYear
   *   The year interval starts on.
   * @property {string?} endDate
   *   The full date of the end of the interval.
   * @property {string?} endTime
   *   The time part of the end of the interval.
   * @property {string?} endDay
   *   The day (of the month) the interval ends on.
   * @property {string?} endMonth
   *   The month the interval ends on.
   * @property {string?} endYear
   *   The year interval ends on.
   * @property {string?} month
   *   The month the interval occurs in when the start is all day and the
   *   interval does not span multiple months.
   * @property {string?} year
   *   The year the interval occurs in when the the start is all day and the
   *   interval does not span multiple years.
   */

  /**
   * Format a date interval into various parts suitable for building
   * strings that describe the interval. This result may leave out some parts of
   * either date based on the closeness of the two.
   *
   * @param {calIDateTime} startDate - The start of the interval.
   * @param {calIDateTime} endDate - The end of the interval.
   * @returns {IntervalParts} An object to be used to create an
   *                                       interval string.
   */
  formatIntervalParts(startDate, endDate) {
    if (endDate == null && startDate == null) {
      return { type: "task-without-dates" };
    }

    if (endDate == null) {
      return {
        type: "task-without-due-date",
        startDate: this.formatDate(startDate),
        startTime: this.formatTime(startDate),
      };
    }

    if (startDate == null) {
      return {
        type: "task-without-start-date",
        endDate: this.formatDate(endDate),
        endTime: this.formatTime(endDate),
      };
    }

    // Here there are only events or tasks with both start and due date.
    // make sure start and end use the same timezone when formatting intervals:
    let testdate = startDate.clone();
    testdate.isDate = true;
    let originalEndDate = endDate.clone();
    endDate = endDate.getInTimezone(startDate.timezone);
    let sameDay = testdate.compare(endDate) == 0;
    if (startDate.isDate) {
      // All-day interval, so we should leave out the time part
      if (sameDay) {
        return {
          type: "all-day",
          startDate: this.formatDateLong(startDate),
        };
      }

      let startDay = this.formatDayWithOrdinal(startDate.day);
      let startYear = String(startDate.year);
      let endDay = this.formatDayWithOrdinal(endDate.day);
      let endYear = String(endDate.year);
      if (startDate.year != endDate.year) {
        return {
          type: "all-day-between-years",
          startDay,
          startMonth: lazy.cal.l10n.formatMonth(
            startDate.month + 1,
            "calendar",
            "daysIntervalBetweenYears"
          ),
          startYear,
          endDay,
          endMonth: lazy.cal.l10n.formatMonth(
            originalEndDate.month + 1,
            "calendar",
            "daysIntervalBetweenYears"
          ),
          endYear,
        };
      }

      if (startDate.month == endDate.month) {
        return {
          type: "all-day-in-month",
          startDay,
          month: lazy.cal.l10n.formatMonth(startDate.month + 1, "calendar", "daysIntervalInMonth"),
          endDay,
          year: endYear,
        };
      }

      return {
        type: "all-day-between-months",
        startDay,
        startMonth: lazy.cal.l10n.formatMonth(
          startDate.month + 1,
          "calendar",
          "daysIntervalBetweenMonths"
        ),
        endDay,
        endMonth: lazy.cal.l10n.formatMonth(
          originalEndDate.month + 1,
          "calendar",
          "daysIntervalBetweenMonths"
        ),
        year: endYear,
      };
    }

    let startDateString = this.formatDate(startDate);
    let startTime = this.formatTime(startDate);
    let endDateString = this.formatDate(endDate);
    let endTime = this.formatTime(endDate);
    // non-allday, so need to return date and time
    if (sameDay) {
      // End is on the same day as start, so we can leave out the end date
      if (startTime == endTime) {
        // End time is on the same time as start, so we can leave out the end time
        // "5 Jan 2006 13:00"
        return {
          type: "same-date-time",
          startDate: startDateString,
          startTime,
        };
      }
      // still include end time
      // "5 Jan 2006 13:00 - 17:00"
      return {
        type: "same-day",
        startDate: startDateString,
        startTime,
        endTime,
      };
    }

    // Spanning multiple days, so need to include date and time
    // for start and end
    // "5 Jan 2006 13:00 - 7 Jan 2006 9:00"
    return {
      type: "several-days",
      startDate: startDateString,
      startTime,
      endDate: endDateString,
      endTime,
    };
  },

  /**
   * Get the monthday followed by its ordinal symbol in the current locale.
   * e.g.  monthday 1 -> 1st
   *       monthday 2 -> 2nd etc.
   *
   * @param {number} aDay - A number from 1 to 31.
   * @returns {string} The monthday number in ordinal format in the current locale.
   */
  formatDayWithOrdinal(aDay) {
    let ordinalSymbols = lazy.gDateStringBundle.GetStringFromName("dayOrdinalSymbol").split(",");
    let dayOrdinalSymbol = ordinalSymbols[aDay - 1] || ordinalSymbols[0];
    return aDay + dayOrdinalSymbol;
  },

  /**
   * Helper to get the start/end dates for a given item.
   *
   * @param {calIItemBase} item - The item to get the dates for.
   * @returns {[calIDateTime, calIDateTime]} An array with start and end date.
   */
  getItemDates(item) {
    let start = item[lazy.cal.dtz.startDateProp(item)];
    let end = item[lazy.cal.dtz.endDateProp(item)];
    let kDefaultTimezone = lazy.cal.dtz.defaultTimezone;
    // Check for tasks without start and/or due date
    if (start) {
      start = start.getInTimezone(kDefaultTimezone);
    }
    if (end) {
      end = end.getInTimezone(kDefaultTimezone);
    }
    // EndDate is exclusive. For all-day events, we need to subtract one day,
    // to get into a format that's understandable.
    if (start && start.isDate && end) {
      end.day -= 1;
    }

    return [start, end];
  },

  /**
   * Format an interval that is defined by an item with the default timezone.
   *
   * @param {calIItemBase} aItem - The item describing the interval.
   * @returns {string} The formatted item interval.
   */
  formatItemInterval(aItem) {
    return this.formatInterval(...this.getItemDates(aItem));
  },

  /**
   * Format a time interval like formatItemInterval, but only show times.
   *
   * @param {calIItemBase} aItem - The item describing the interval.
   * @returns {string} The formatted item interval.
   */
  formatItemTimeInterval(aItem) {
    return this.formatTimeInterval(...this.getItemDates(aItem));
  },

  /**
   * Get the month name.
   *
   * @param {number} aMonthIndex - Zero-based month number (0 is january, 11 is december).
   * @returns {string} The month name in the current locale.
   */
  monthName(aMonthIndex) {
    let oneBasedMonthIndex = aMonthIndex + 1;
    return lazy.gDateStringBundle.GetStringFromName("month." + oneBasedMonthIndex + ".name");
  },

  /**
   * Get the abbreviation of the month name.
   *
   * @param {number} aMonthIndex - Zero-based month number (0 is january, 11 is december).
   * @returns {string} The abbreviated month name in the current locale.
   */
  shortMonthName(aMonthIndex) {
    let oneBasedMonthIndex = aMonthIndex + 1;
    return lazy.gDateStringBundle.GetStringFromName("month." + oneBasedMonthIndex + ".Mmm");
  },

  /**
   * Get the day name.
   *
   * @param {number} aMonthIndex - Zero-based day number (0 is sunday, 6 is saturday).
   * @returns {string} The day name in the current locale.
   */
  dayName(aDayIndex) {
    let oneBasedDayIndex = aDayIndex + 1;
    return lazy.gDateStringBundle.GetStringFromName("day." + oneBasedDayIndex + ".name");
  },

  /**
   * Get the abbreviation of the day name.
   *
   * @param {number} aMonthIndex - Zero-based day number (0 is sunday, 6 is saturday).
   * @returns {string} The abbrevidated day name in the current locale.
   */
  shortDayName(aDayIndex) {
    let oneBasedDayIndex = aDayIndex + 1;
    return lazy.gDateStringBundle.GetStringFromName("day." + oneBasedDayIndex + ".Mmm");
  },
};

/**
 * Determine whether a datetime is specified relative to the user, i.e. a date
 * or floating datetime, both of which should be displayed the same regardless
 * of the user's time zone.
 *
 * @param {calIDateTime} dateTime The datetime object to check.
 * @returns {boolean}
 */
function isDateTimeRelativeToUser(dateTime) {
  return dateTime.isDate || dateTime.timezone.isFloating;
}

/**
 * Format a datetime object as a string with a given set of formatting options.
 *
 * @param {calIDateTime} dateTime The datetime object to be formatted.
 * @param {object} options
 *  The set of Intl.DateTimeFormat options to use for formatting.
 * @returns {string} A formatted string representing the given datetime.
 */
function formatDateTimeWithOptions(dateTime, options) {
  const jsDate = getDateTimeAsAdjustedJsDate(dateTime);

  // We want floating datetimes and dates to be formatted without regard to
  // timezone; everything else has been adjusted so that "UTC" will produce the
  // correct result because we cannot guarantee that the datetime's timezone is
  // supported by Gecko.
  const timezone = isDateTimeRelativeToUser(dateTime) ? undefined : "UTC";

  return getFormatter({ ...options, timeZone: timezone }).format(jsDate);
}

/**
 * Convert a calendar datetime object to a JavaScript standard Date adjusted
 * for timezone offset.
 *
 * @param {calIDateTime} dateTime The datetime object to convert and adjust.
 * @returns {Date} The standard JS equivalent of the given datetime, offset
 *                 from UTC according to the datetime's timezone.
 */
function getDateTimeAsAdjustedJsDate(dateTime) {
  const unadjustedJsDate = lazy.cal.dtz.dateTimeToJsDate(dateTime);

  // If the datetime is date-only, it doesn't make sense to adjust for timezone.
  // Floating datetimes likewise are not fixed in a single timezone.
  if (isDateTimeRelativeToUser(dateTime)) {
    return unadjustedJsDate;
  }

  // We abuse `Date` slightly here: its internal representation is intended to
  // contain the date as seconds from the epoch, but `Intl` relies on adjusting
  // timezone and we can't be sure we have a recognized timezone ID. Instead, we
  // force the internal representation to compensate for timezone offset.
  const offsetInMs = dateTime.timezoneOffset * 1000;
  return new Date(unadjustedJsDate.valueOf() + offsetInMs);
}

/**
 * Get a formatter that can be used to format a date-time in a
 * locale-appropriate way.
 *
 * NOTE: formatters are cached for future requests.
 *
 * @param {object} formatOptions - Intl.DateTimeFormatter options.
 *
 * @returns {DateTimeFormatter} - The formatter.
 */
function getFormatter(formatOptions) {
  let cacheKey = JSON.stringify(formatOptions);
  if (formatCache.has(cacheKey)) {
    return formatCache.get(cacheKey);
  }

  // Use en-US when running in a test to make the result independent of the test
  // machine.
  let locale = Services.appinfo.name == "xpcshell" ? "en-US" : undefined;
  let formatter;
  if ("hourCycle" in formatOptions) {
    // FIXME: The hourCycle property is currently ignored by Services.intl, so
    // we use Intl instead. Once bug 1749459 is closed, we should only use
    // Services.intl again.
    formatter = new Intl.DateTimeFormat(locale, formatOptions);
  } else {
    formatter = new Services.intl.DateTimeFormat(locale, formatOptions);
  }

  formatCache.set(cacheKey, formatter);
  return formatter;
}